diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52be444..86c6569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: run: | python -m pip install --upgrade pip wheel pip install -r dev-requirements.txt - pip install -e .[R] + pip install -e .[R,julia] - name: Setup Apptainer uses: eWaterCycle/setup-apptainer@v2 with: @@ -45,13 +45,18 @@ jobs: run: | Rscript -e "install.packages('remotes')" Rscript -e "install.packages('R6')" + - name: Install Julia + uses: julia-actions/setup-julia@v1 + with: + version: '^1.9' - name: Test with pytest run: | pytest -vv --cov=grpc4bmi --cov-report xml + timeout-minutes: 20 - name: Correct coverage paths run: sed -i "s+$PWD/++g" coverage.xml - name: SonarCloud analysis - uses: sonarsource/sonarcloud-github-action@v1.3 + uses: sonarsource/sonarcloud-github-action@v2.0.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/README.md b/README.md index f6622cd..5921afa 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ on the client (Python) side. If your server model is implemented in Python, do t pip install grpc4bmi[R] ``` +If the model is implemented in Julia, run instead + +```bash +pip install grpc4bmi[julia] +``` + in the server environment. For bleeding edge version from GitHub use ```bash @@ -90,6 +96,35 @@ For example with [WALRUS](https://github.com/eWaterCycle/grpc4bmi-examples/tree/ run-bmi-server --lang R --path ~/git/eWaterCycle/grpc4bmi-examples/walrus/walrus-bmi.r --name WalrusBmi --port 55555 ``` +### Models written in Julia + +The grpc4bmi Python package can also run BMI models written in Julia if the model has an implementation of the [BasicModelInterface.jl](https://github.com/Deltares/BasicModelInterface.jl). + +Run the Julia model in Python with + +```bash +from grpc4bmi.bmi_julia_model import BmiJulia + +mymodel = BmiJulia.from_name('.', 'BasicModelInterface') +``` + +For example with [Wflow.jl](https://github.com/Deltares/Wflow.jl/) use + +```bash +# Install Wflow.jl package in the Julia environment managed by the juliacall Python package. +from juliacall import Main as jl +jl.Pkg.add("Wflow") +# Create the model +from grpc4bmi.bmi_julia_model import BmiJulia +mymodel = BmiJulia.from_name('Wflow.Model', 'Wflow.bmi.BMI') +``` + +A Julia model has to be run locally. It can not be run in the default gRPC client/server Docker container mode because: + +1. Julia has no gRPC server implementation +2. Calling Julia methods from Python gRPC server causes 100% CPU usage and no progress +3. Calling Julia methods from C++ gRPC server causes segmentation faults + ### The client side The client side has only a Python implementation. The default BMI client assumes a running server process on a given port. @@ -154,7 +189,3 @@ pip install -e .[docs] and install the C++ runtime and `protoc` command as described in . After this, simply executing the `proto_gen.sh` script should do the job. - -## Future work - -More language bindings are underway. diff --git a/docs/container/building.rst b/docs/container/building.rst index 6a47255..8511be8 100644 --- a/docs/container/building.rst +++ b/docs/container/building.rst @@ -68,6 +68,11 @@ The WALRUS model has a `Dockerfile`_ file which can be used as an example. .. _Dockerfile: https://github.com/eWaterCycle/grpc4bmi-examples/blob/master/walrus/Dockerfile +Julia +----- + +A Julia model can not be run as a server, see https://github.com/eWaterCycle/grpc4bmi/blob/main/README.md#model-written-in-julia . + C/C++/Fortran ------------- diff --git a/grpc4bmi/bmi_client_docker.py b/grpc4bmi/bmi_client_docker.py index 328400a..e102c5c 100644 --- a/grpc4bmi/bmi_client_docker.py +++ b/grpc4bmi/bmi_client_docker.py @@ -102,7 +102,8 @@ def __init__(self, image: str, work_dir: str, image_port=50051, host=None, super(BmiClientDocker, self).__init__(BmiClient.create_grpc_channel(port=port, host=host), timeout=timeout) def __del__(self): - self.container.stop() + if hasattr(self, 'container'): + self.container.stop() def logs(self) -> str: """Returns complete combined stdout and stderr written by the Docker container. diff --git a/grpc4bmi/bmi_client_subproc.py b/grpc4bmi/bmi_client_subproc.py index 4e2104a..6e81d92 100644 --- a/grpc4bmi/bmi_client_subproc.py +++ b/grpc4bmi/bmi_client_subproc.py @@ -15,19 +15,19 @@ class BmiClientSubProcess(BmiClient): >>> mymodel = BmiClientSubProcess(..) """ - def __init__(self, module_name, path=None, timeout=None): + def __init__(self, module_name, path=None, timeout=None, delay=1): host = "localhost" port = BmiClient.get_unique_port(host) name_options = ["--name", module_name] port_options = ["--port", str(port)] path_options = ["--path", path] if path else [] self.pipe = subprocess.Popen(["run-bmi-server"] + name_options + port_options + path_options, env=dict(os.environ)) - time.sleep(1) + time.sleep(delay) super(BmiClientSubProcess, self).__init__(BmiClient.create_grpc_channel(port=port, host=host), timeout=timeout) def __del__(self): - self.pipe.terminate() - self.pipe.wait() + self.pipe.kill() + self.pipe.wait(timeout=0.1) def get_value_ref(self, var_name): raise NotImplementedError("Cannot exchange memory references across process boundary") diff --git a/grpc4bmi/bmi_julia_model.py b/grpc4bmi/bmi_julia_model.py new file mode 100644 index 0000000..2dd36a3 --- /dev/null +++ b/grpc4bmi/bmi_julia_model.py @@ -0,0 +1,669 @@ +from typing import List + +from bmipy import Bmi +import numpy as np +from juliacall import Main as jl, ModuleValue, TypeValue + +class BmiJulia(Bmi): + """Python Wrapper of a Julia based implementation of BasicModelInterface. + + BasicModelInterface is available in https://github.com/Deltares/BasicModelInterface.jl repo. + + Args: + model: Julia model class + implementation: Julia variable which implements BasicModelInterface + """ + state = None + + @classmethod + def from_name(cls, model_name, implementation_name = 'BasicModelInterface'): + """Construct BmiJulia from Julia model class name and implementation name. + + Args: + model_name: Name of Julia model class + The package of model_name should be installed. + implementation_name: Name of Julia variable which implements BasicModelInterface + The package of implementation_name should be installed. + Uses https://juliapackages.com/p/basicmodelinterface by default. + """ + package4model = model_name.split('.')[0] + package4implementation = implementation_name.split('.')[0] + jl.seval("import " + package4model) + jl.seval("import " + package4implementation) + model = jl.seval(model_name) + implementation = jl.seval(implementation_name) + return BmiJulia(model, implementation) + + def __init__(self, model: TypeValue, implementation: ModuleValue): + self.model = model + self.implementation = implementation + + def initialize(self, config_file: str) -> None: + """Perform startup tasks for the model. + Perform all tasks that take place before entering the model's time + loop, including opening files and initializing the model state. Model + inputs are read from a text-based configuration file, specified by + `config_file`. + Parameters + ---------- + config_file : str, optional + The path to the model configuration file. + Notes + ----- + Models should be refactored, if necessary, to use a + configuration file. CSDMS does not impose any constraint on + how configuration files are formatted, although YAML is + recommended. A template of a model's configuration file + with placeholder values is used by the BMI. + """ + self.state = self.implementation.initialize(self.model, config_file) + + def update(self) -> None: + """Advance model state by one time step. + Perform all tasks that take place within one pass through the model's + time loop. This typically includes incrementing all of the model's + state variables. If the model's state variables don't change in time, + then they can be computed by the :func:`initialize` method and this + method can return with no action. + """ + self.implementation.update(self.state) + + def update_until(self, time: float) -> None: + """Advance model state until the given time. + Parameters + ---------- + time : float + A model time later than the current model time. + """ + self.implementation.update_until(self.state, time) + + def finalize(self) -> None: + """Perform tear-down tasks for the model. + Perform all tasks that take place after exiting the model's time + loop. This typically includes deallocating memory, closing files and + printing reports. + """ + self.implementation.finalize(self.state) + + def get_component_name(self) -> str: + """Name of the component. + Returns + ------- + str + The name of the component. + """ + return self.implementation.get_component_name(self.state) + + def get_input_item_count(self) -> int: + """Count of a model's input variables. + Returns + ------- + int + The number of input variables. + """ + return self.implementation.get_input_item_count(self.state) + + def get_output_item_count(self) -> int: + """Count of a model's output variables. + Returns + ------- + int + The number of output variables. + """ + return self.implementation.get_output_item_count(self.state) + + def get_input_var_names(self) -> List[str]: + """List of a model's input variables. + Input variable names must be CSDMS Standard Names, also known + as *long variable names*. + Returns + ------- + list of str + The input variables for the model. + Notes + ----- + Standard Names enable the CSDMS framework to determine whether + an input variable in one model is equivalent to, or compatible + with, an output variable in another model. This allows the + framework to automatically connect components. + Standard Names do not have to be used within the model. + """ + return list(self.implementation.get_input_var_names(self.state)) + + def get_output_var_names(self) -> List[str]: + """List of a model's output variables. + Output variable names must be CSDMS Standard Names, also known + as *long variable names*. + Returns + ------- + list of str + The output variables for the model. + """ + return list(self.implementation.get_output_var_names(self.state)) + + def get_var_grid(self, name: str) -> int: + """Get grid identifier for the given variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + int + The grid identifier. + """ + return self.implementation.get_var_grid(self.state, name) + + def get_var_type(self, name: str) -> str: + """Get data type of the given variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + str + The Python variable type; e.g., ``str``, ``int``, ``float``. + """ + return self.implementation.get_var_type(self.state, name).lower() + + def get_var_units(self, name: str) -> str: + """Get units of the given variable. + Standard unit names, in lower case, should be used, such as + ``meters`` or ``seconds``. Standard abbreviations, like ``m`` for + meters, are also supported. For variables with compound units, + each unit name is separated by a single space, with exponents + other than 1 placed immediately after the name, as in ``m s-1`` + for velocity, ``W m-2`` for an energy flux, or ``km2`` for an + area. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + str + The variable units. + Notes + ----- + CSDMS uses the `UDUNITS`_ standard from Unidata. + .. _UDUNITS: http://www.unidata.ucar.edu/software/udunits + """ + return self.implementation.get_var_units(self.state, name) + + def get_var_itemsize(self, name: str) -> int: + """Get memory use for each array element in bytes. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + int + Item size in bytes. + """ + return self.implementation.get_var_itemsize(self.state, name) + + def get_var_nbytes(self, name: str) -> int: + """Get size, in bytes, of the given variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + int + The size of the variable, counted in bytes. + """ + return self.implementation.get_var_nbytes(self.state, name) + + def get_var_location(self, name: str) -> str: + """Get the grid element type that the a given variable is defined on. + The grid topology can be composed of *nodes*, *edges*, and *faces*. + *node* + A point that has a coordinate pair or triplet: the most + basic element of the topology. + *edge* + A line or curve bounded by two *nodes*. + *face* + A plane or surface enclosed by a set of edges. In a 2D + horizontal application one may consider the word “polygon”, + but in the hierarchy of elements the word “face” is most common. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + str + The grid location on which the variable is defined. Must be one of + `"node"`, `"edge"`, or `"face"`. + Notes + ----- + CSDMS uses the `ugrid conventions`_ to define unstructured grids. + .. _ugrid conventions: http://ugrid-conventions.github.io/ugrid-conventions + """ + return self.implementation.get_var_location(self.state, name) + + def get_current_time(self) -> float: + """Current time of the model. + Returns + ------- + float + The current model time. + """ + return self.implementation.get_current_time(self.state) + + def get_start_time(self) -> float: + """Start time of the model. + Model times should be of type float. + Returns + ------- + float + The model start time. + """ + return self.implementation.get_start_time(self.state) + + def get_end_time(self) -> float: + """End time of the model. + Returns + ------- + float + The maximum model time. + """ + return self.implementation.get_end_time(self.state) + + def get_time_units(self) -> str: + """Time units of the model. + Returns + ------- + str + The model time unit; e.g., `days` or `s`. + Notes + ----- + CSDMS uses the UDUNITS standard from Unidata. + """ + return self.implementation.get_time_units(self.state) + + def get_time_step(self) -> float: + """Current time step of the model. + The model time step should be of type float. + Returns + ------- + float + The time step used in model. + """ + return self.implementation.get_time_step(self.state) + + # pylint: disable=arguments-differ + def get_value(self, name: str, dest: np.ndarray) -> np.ndarray: + """Get a copy of values of the given variable. + This is a getter for the model, used to access the model's + current state. It returns a *copy* of a model variable, with + the return type, size and rank dependent on the variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + + Returns + ------- + ndarray + A numpy array containing the requested value(s). + """ + dest[:] = self.implementation.get_value( + self.state, name, jl.convert(jl.Vector, dest) + ) + return dest + + def get_value_ptr(self, name: str) -> np.ndarray: + """Get a reference to values of the given variable. + This is a getter for the model, used to access the model's + current state. It returns a reference to a model variable, + with the return type, size and rank dependent on the variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + Returns + ------- + array_like + A reference to a model variable. + """ + raise NotImplementedError( + "This method is incompatible with Julia-Python interface" + ) + + # pylint: disable=arguments-differ + def get_value_at_indices(self, name: str, dest: np.ndarray, inds: np.ndarray) -> np.ndarray: + """Get values at particular indices. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + dest : ndarray + A numpy array into which to place the values. + inds : array_like + The indices into the variable array. + Returns + ------- + array_like + Value of the model variable at the given location. + """ + dest[:] = self.implementation.get_value_at_indices( + self.state, + name, + jl.convert(jl.Vector, dest), + jl.convert(jl.Vector, inds + 1) + ) + return dest + + def set_value(self, name: str, src: np.ndarray) -> None: + """Specify a new value for a model variable. + This is the setter for the model, used to change the model's + current state. It accepts, through *values*, a new value for a + model variable, with the type, size and rank of *values* + dependent on the variable. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + values : array_like + The new value for the specified variable. + """ + self.implementation.set_value(self.state, name, + jl.convert(jl.Vector, src) + ) + + def set_value_at_indices( + self, name: str, inds: np.ndarray, src: np.ndarray + ) -> None: + """Specify a new value for a model variable at particular indices. + Parameters + ---------- + name : str + An input or output variable name, a CSDMS Standard Name. + inds : array_like + The indices into the variable array. + src : array_like + The new value for the specified variable. + """ + self.implementation.set_value_at_indices( + self.state, + name, + jl.convert(jl.Vector, inds + 1), + jl.convert(jl.Vector, src), + ) + + # Grid information + def get_grid_rank(self, grid: int) -> int: + """Get number of dimensions of the computational grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + int + Rank of the grid. + """ + return self.implementation.get_grid_rank(self.state, grid) + + def get_grid_size(self, grid: int) -> int: + """Get the total number of elements in the computational grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + int + Size of the grid. + """ + return self.implementation.get_grid_size(self.state, grid) + + def get_grid_type(self, grid: int) -> str: + """Get the grid type as a string. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + str + Type of grid as a string. + """ + return self.implementation.get_grid_type(self.state, grid) + + # Uniform rectilinear + def get_grid_shape(self, grid: int, shape: np.ndarray) -> np.ndarray: + """Get dimensions of the computational grid. + Parameters + ---------- + grid : int + A grid identifier. + + Returns + ------- + ndarray of int + A numpy array that holds the grid's shape. + """ + shape[:] = self.implementation.get_grid_shape( + self.state, grid, jl.convert(jl.Vector, shape), + ) + return shape + + def get_grid_spacing(self, grid: int, spacing: np.ndarray) -> np.ndarray: + """Get distance between nodes of the computational grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + ndarray of float + A numpy array that holds the grid's spacing between grid rows and columns. + """ + spacing[:] = self.implementation.get_grid_spacing( + self.state, grid, jl.convert(jl.Vector, spacing), + ) + return spacing + + def get_grid_origin(self, grid: int, origin: np.ndarray) -> np.ndarray: + """Get coordinates for the lower-left corner of the computational grid. + Parameters + ---------- + grid : int + A grid identifier. + + Returns + ------- + ndarray of float + A numpy array that holds the coordinates of the grid's + lower-left corner. + """ + origin[:] = self.implementation.get_grid_origin( + self.state, grid, jl.convert(jl.Vector, origin), + ) + return origin + + # Non-uniform rectilinear, curvilinear + def get_grid_x(self, grid: int, x: np.ndarray) -> np.ndarray: + """Get coordinates of grid nodes in the x direction. + Parameters + ---------- + grid : int + A grid identifier. + x: An array to hold the x-coordinates of the grid nodes. + + + Returns + ------- + ndarray of float + The input numpy array that holds the grid's column x-coordinates. + """ + x[:] = self.implementation.get_grid_x( + self.state, grid, jl.convert(jl.Vector, x), + ) + return x + + def get_grid_y(self, grid: int, y: np.ndarray) -> np.ndarray: + """Get coordinates of grid nodes in the y direction. + Parameters + ---------- + grid : int + A grid identifier. + y: + An array to hold the y-coordinates of the grid nodes. + + Returns + ------- + ndarray of float + The input numpy array that holds the grid's row y-coordinates. + """ + y[:] = self.implementation.get_grid_y( + self.state, grid, jl.convert(jl.Vector, y), + ) + return y + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + """Get coordinates of grid nodes in the z direction. + Parameters + ---------- + grid : int + A grid identifier. + z : ndarray of float, shape *(nlayers,)* + A numpy array to hold the z-coordinates of the grid nodes layers. + Returns + ------- + ndarray of float + The input numpy array that holds the grid's layer z-coordinates. + """ + z[:] = self.implementation.get_grid_z( + self.state, grid, jl.convert(jl.Vector, z), + ) + return z + + def get_grid_node_count(self, grid: int) -> int: + """Get the number of nodes in the grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + int + The total number of grid nodes. + """ + return self.implementation.get_grid_node_count(self.state, grid) + + def get_grid_edge_count(self, grid: int) -> int: + """Get the number of edges in the grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + int + The total number of grid edges. + """ + return self.implementation.get_grid_edge_count(self.state, grid) + + def get_grid_face_count(self, grid: int) -> int: + """Get the number of faces in the grid. + Parameters + ---------- + grid : int + A grid identifier. + Returns + ------- + int + The total number of grid faces. + """ + return self.implementation.get_grid_face_count(self.state, grid) + + def get_grid_edge_nodes(self, grid: int, edge_nodes: np.ndarray) -> np.ndarray: + """Get the edge-node connectivity. + Parameters + ---------- + grid : int + A grid identifier. + edge_nodes: An array of integers to place the edge-node connectivity. + For each edge, connectivity is given as node at edge tail, + followed by node at edge head. Therefore this array must be twice + the number of nodes long. + + Returns + ------- + ndarray of int, shape *(2 x nnodes,)* + A numpy array that holds the edge-node connectivity. For each edge, + connectivity is given as node at edge tail, followed by node at + edge head. + """ + edge_nodes[:] = self.implementation.get_grid_edge_nodes( + self.state, grid, jl.convert(jl.Vector, edge_nodes), + ) + return edge_nodes + + def get_grid_face_edges(self, grid: int, face_edges: np.ndarray) -> np.ndarray: + """Get the face-edge connectivity. + + Parameters + ---------- + grid : int + A grid identifier. + face_edges: + An array of integers in which to place the face-edge connectivity. + + Returns + ------- + ndarray of int + A numpy array that holds the face-edge connectivity. + """ + face_edges[:] = self.implementation.get_grid_face_edges( + self.state, grid, jl.convert(jl.Vector, face_edges), + ) + return face_edges + + def get_grid_face_nodes(self, grid: int, face_nodes: np.ndarray) -> np.ndarray: + """Get the face-node connectivity. + + Parameters + ---------- + grid : int + A grid identifier. + face_nodes : ndarray of int + A numpy array to place the face-node connectivity. For each face, + the nodes (listed in a counter-clockwise direction) that form the + boundary of the face. + + Returns + ------- + ndarray of int + A numpy array that holds the face-node connectivity. For each face, + the nodes (listed in a counter-clockwise direction) that form the + boundary of the face. + """ + face_nodes[:] = self.implementation.get_grid_face_nodes( + self.state, grid, jl.convert(jl.Vector, face_nodes), + ) + return face_nodes + + def get_grid_nodes_per_face(self, grid: int, nodes_per_face: np.ndarray) -> np.ndarray: + """Get the number of nodes for each face. + Parameters + ---------- + grid : int + A grid identifier. + nodes_per_face : ndarray of int, shape *(nfaces,)* + A numpy array to place the number of nodes per face. + Returns + ------- + ndarray of int, shape *(nfaces,)* + A numpy array that holds the number of nodes per face. + """ + nodes_per_face[:] = self.implementation.get_grid_nodes_per_face( + self.state, grid, jl.convert(jl.Vector, nodes_per_face), + ) + return nodes_per_face diff --git a/pyproject.toml b/pyproject.toml index 5cc16c9..9c86158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ [project.optional-dependencies] R = ["rpy2"] +julia = ["juliacall"] dev = [ "build", "pytest", diff --git a/test/fake.jl b/test/fake.jl new file mode 100644 index 0000000..494c219 --- /dev/null +++ b/test/fake.jl @@ -0,0 +1,43 @@ +module FakeModel + +import BasicModelInterface as BMI + +Base.@kwdef mutable struct Model +end + +BMI.initialize(::Type{Model}, config_file) = Model() + +BMI.get_component_name(m::Model) = "The 2D Heat Equation" + +function BMI.get_grid_x(m::Model, grid, x::Vector{T}) where {T<:AbstractFloat} + copyto!(x, [1.0, 2.0]) +end + +function BMI.get_grid_y(m::Model, grid, y) + copyto!(y, [3.0, 4.0]) +end + +function BMI.get_grid_z(m::Model, grid, z) + copyto!(z, [5.0, 6.0]) +end + +BMI.get_grid_edge_count(m::Model, grid) = 10 +BMI.get_grid_face_count(m::Model, grid) = 11 + +function BMI.get_grid_edge_nodes(m::Model, grid, edge_nodes) + copyto!(edge_nodes, [7.0, 8.0]) +end + +function BMI.get_grid_face_edges(m::Model, grid, face_edges) + copyto!(face_edges, [9.0, 10.0]) +end + +function BMI.get_grid_face_nodes(m::Model, grid, face_nodes) + copyto!(face_nodes, [11.0, 12.0]) +end + +function BMI.get_grid_nodes_per_face(m::Model, grid, nodes_per_face) + copyto!(nodes_per_face, [13.0, 14.0]) +end + +end # FakeModel module diff --git a/test/heat-images/c-julia/CMakeLists.txt b/test/heat-images/c-julia/CMakeLists.txt new file mode 100644 index 0000000..ab127cd --- /dev/null +++ b/test/heat-images/c-julia/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.0) +project(run_bmi_server CXX) + +set(CMAKE_MACOSX_RPATH 1) +set(CMAKE_SKIP_BUILD_RPATH FALSE) +set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) + +set(_cflags "-ansi -Wall -Wundef -Wstrict-prototypes -Wmissing-prototypes -Wmissing-declarations -O2") +find_package(PkgConfig REQUIRED) +pkg_check_modules(BMICXX REQUIRED IMPORTED_TARGET bmicxx) +message("-- bmicxx include - ${BMICXX_INCLUDE_DIRS}") +include_directories(${BMICXX_INCLUDE_DIRS}) +pkg_check_modules(GRPC4BMI REQUIRED grpc4bmi) + +# See https://github.com/barche/embedding-julia +set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}) +find_package(Julia REQUIRED) + +add_definitions(-DJULIA_ENABLE_THREADING) + +add_executable(run_bmi_server run_bmi_server.cc) + +target_include_directories(run_bmi_server PRIVATE "$") + +target_link_libraries(run_bmi_server + ${GRPC4BMI_LINK_LIBRARIES} + $ + ) + +install(TARGETS run_bmi_server + RUNTIME DESTINATION bin) diff --git a/test/heat-images/c-julia/Dockerfile b/test/heat-images/c-julia/Dockerfile new file mode 100644 index 0000000..6be7aff --- /dev/null +++ b/test/heat-images/c-julia/Dockerfile @@ -0,0 +1,93 @@ +# Build with +# +# docker build -t heat:c-julia -f test/heat-images/c-julia/Dockerfile . +# +# docker run -ti --rm -p 55555:55555 heat:c-julia +# +# Run with +# +# ipython +# from grpc4bmi.bmi_client_docker import BmiClientDocker +# model = BmiClientDocker('heat:c-julia', work_dir='/tmp', delay=1) +# model.get_component_name() +# model.get_output_var_names() +# from pathlib import Path +# config = Path('/tmp/heat.config.txt') +# config.write_text('1.5, 8.0, 7, 6') +# model.initialize(str(config)) +# model.update() +# import numpy as np +# model.get_value('plate_surface__temperature', np.zeros(42)) +# +FROM julia:bullseye AS builder + +# Install build deps +RUN apt-get update && apt-get install -qy git build-essential cmake autoconf libtool pkg-config libssl-dev + +# Compile gRPC +WORKDIR /opt/grpc +ARG GRPC_VERSION=v1.51.1 +RUN echo ${GRPC_VERSION} +RUN git clone -b ${GRPC_VERSION} --depth 1 https://github.com/grpc/grpc . && git submodule update --init +WORKDIR /opt/grpc/cmake/build +RUN cmake -DgRPC_INSTALL=ON -DgRPC_SSL_PROVIDER=package -DgRPC_BUILD_TESTS=OFF -DBUILD_SHARED_LIBS=ON ../.. \ + && make -j6 && make install && ldconfig + +# Install bmi-cxx +ARG BMICXX_VERSION=v2.0 +RUN git clone -b ${BMICXX_VERSION} https://github.com/csdms/bmi-cxx.git /opt/bmi-cxx +WORKDIR /opt/bmi-cxx/build +RUN cmake .. && make install + +# Install bmi-c +ARG BMIC_VERSION=v2.1 +RUN git clone -b ${BMIC_VERSION} https://github.com/csdms/bmi-c.git /opt/bmi-c +WORKDIR /opt/bmi-c/build +RUN cmake .. && make install + +# Install grpc4bmi, use commit c5cc9b9bf33b6043e8db242a07c6fb92b9c63f66 +# head of bmi2 branch, PR https://github.com/eWaterCycle/grpc4bmi/pull/124 +# ARG GRPC4BMI_VERSION=c5cc9b9bf33b6043e8db242a07c6fb92b9c63f66 +# RUN git clone https://github.com/eWaterCycle/grpc4bmi /opt/grpc4bmi \ +# && cd /opt/grpc4bmi && git checkout ${GRPC4BMI_VERSION} +COPY cpp /opt/grpc4bmi/cpp +COPY proto /opt/grpc4bmi/proto +WORKDIR /opt/grpc4bmi/cpp/build +RUN cmake .. && make install + + +# Compile main +WORKDIR /opt/grpc4bmiheatc-julia/build +COPY test/heat-images/c-julia/FindJulia.cmake /opt/grpc4bmiheatc-julia +COPY test/heat-images/c-julia/run_bmi_server.cc /opt/grpc4bmiheatc-julia +COPY test/heat-images/c-julia/CMakeLists.txt /opt/grpc4bmiheatc-julia +RUN cmake .. && make install + +FROM julia:bullseye AS jldeps + +# Install heat-julia +RUN julia -e 'using Pkg; Pkg.add(url="https://github.com/csdms/bmi-example-julia.git",rev="80c34b4f2217599e600fe9372b1bae50e1229edf")' && \ + julia -e 'using Pkg; Pkg.add("BasicModelInterface")' + +# TODO use config file from outside container +RUN curl -L https://github.com/csdms/bmi-example-julia/raw/main/example/heat.toml > /usr/local/share/heat.toml + +# run container +FROM julia:bullseye + +# Install runtime deps +RUN apt-get update && apt-get install -qy libssl1.1 && rm -rf /var/lib/apt/lists/* + +# Copy compiled and deps +COPY --from=builder /usr/local/bin/run_bmi_server /usr/local/bin/run_bmi_server +COPY --from=builder /usr/local/lib/ /usr/local/lib/ +COPY --from=jldeps /root/.julia/ /root/.julia/ +COPY --from=jldeps /usr/local/share/heat.toml /usr/local/share/heat.toml + +RUN echo '/usr/local/julia/lib' > /etc/ld.so.conf.d/julia.conf && ldconfig + +# TODO run server as non-root user + +ENTRYPOINT ["/usr/local/bin/run_bmi_server"] + +# TODO document how to use this Dockerfile together with another Julia BMI model diff --git a/test/heat-images/c-julia/FindJulia.cmake b/test/heat-images/c-julia/FindJulia.cmake new file mode 100644 index 0000000..6af9f6e --- /dev/null +++ b/test/heat-images/c-julia/FindJulia.cmake @@ -0,0 +1,133 @@ + +if(Julia_FOUND) + return() +endif() + +#################### +# Julia Executable # +#################### + +find_program(Julia_EXECUTABLE julia DOC "Julia executable") +MESSAGE(STATUS "Julia_EXECUTABLE: ${Julia_EXECUTABLE}") + +################# +# Julia Version # +################# + +execute_process( + COMMAND "${Julia_EXECUTABLE}" --startup-file=no --version + OUTPUT_VARIABLE Julia_VERSION_STRING +) + +string( + REGEX REPLACE ".*([0-9]+\\.[0-9]+\\.[0-9]+).*" "\\1" + Julia_VERSION_STRING "${Julia_VERSION_STRING}" +) + +MESSAGE(STATUS "Julia_VERSION_STRING: ${Julia_VERSION_STRING}") + +################## +# Julia Includes # +################## + +if(DEFINED ENV{JULIA_INCLUDE_DIRS}) + set(Julia_INCLUDE_DIRS $ENV{JULIA_INCLUDE_DIRS} + CACHE STRING "Location of Julia include files") +else() + execute_process( + COMMAND ${Julia_EXECUTABLE} --startup-file=no -E "julia_include_dir = joinpath(match(r\"(.*)(bin)\",Sys.BINDIR).captures[1],\"include\",\"julia\")\n + if !isdir(julia_include_dir) # then we're running directly from build\n + julia_base_dir_aux = splitdir(splitdir(Sys.BINDIR)[1])[1] # useful for running-from-build\n + julia_include_dir = joinpath(julia_base_dir_aux, \"usr\", \"include\" )\n + julia_include_dir *= \";\" * joinpath(julia_base_dir_aux, \"src\", \"support\" )\n + julia_include_dir *= \";\" * joinpath(julia_base_dir_aux, \"src\" )\n + end\n + julia_include_dir" + OUTPUT_VARIABLE Julia_INCLUDE_DIRS + ) + + string(REGEX REPLACE "\"" "" Julia_INCLUDE_DIRS "${Julia_INCLUDE_DIRS}") + string(REGEX REPLACE "\n" "" Julia_INCLUDE_DIRS "${Julia_INCLUDE_DIRS}") + set(Julia_INCLUDE_DIRS ${Julia_INCLUDE_DIRS} + CACHE PATH "Location of Julia include files") +endif() +MESSAGE(STATUS "Julia_INCLUDE_DIRS: ${Julia_INCLUDE_DIRS}") + +################### +# Julia Libraries # +################### + +execute_process( + COMMAND ${Julia_EXECUTABLE} --startup-file=no -E "using Libdl; abspath(dirname(Libdl.dlpath(\"libjulia\")))" + OUTPUT_VARIABLE Julia_LIBRARY_DIR +) + +string(REGEX REPLACE "\"" "" Julia_LIBRARY_DIR "${Julia_LIBRARY_DIR}") +string(REGEX REPLACE "\n" "" Julia_LIBRARY_DIR "${Julia_LIBRARY_DIR}") + +string(STRIP "${Julia_LIBRARY_DIR}" Julia_LIBRARY_DIR) +set(Julia_LIBRARY_DIR "${Julia_LIBRARY_DIR}" + CACHE PATH "Julia library directory") + +if(WIN32) + set(CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES} .a) + find_library(Julia_LIBRARY + NAMES libjulia.dll.a + PATHS ${Julia_LIBRARY_DIR}//..//lib + NO_DEFAULT_PATH + ) +else() + find_library(Julia_LIBRARY + NAMES julia libjulia + PATHS ${Julia_LIBRARY_DIR} + NO_DEFAULT_PATH + ) +endif() + +MESSAGE(STATUS "Julia_LIBRARY_DIR: ${Julia_LIBRARY_DIR}") +MESSAGE(STATUS "Julia_LIBRARY: ${Julia_LIBRARY}") + +############## +# Sys.BINDIR # +############## + +execute_process( + COMMAND ${Julia_EXECUTABLE} --startup-file=no -E "Sys.BINDIR" + OUTPUT_VARIABLE Sys.BINDIR +) + +string(REGEX REPLACE "\"" "" Sys.BINDIR "${Sys.BINDIR}") +string(REGEX REPLACE "\n" "" Sys.BINDIR "${Sys.BINDIR}") + +MESSAGE(STATUS "Sys.BINDIR: ${Sys.BINDIR}") + +################### +# libLLVM version # +################### + +execute_process( + COMMAND ${Julia_EXECUTABLE} --startup-file=no -E "Base.libllvm_version" + OUTPUT_VARIABLE Julia_LLVM_VERSION +) + +string(REGEX REPLACE "\"" "" Julia_LLVM_VERSION "${Julia_LLVM_VERSION}") +string(REGEX REPLACE "\n" "" Julia_LLVM_VERSION "${Julia_LLVM_VERSION}") + +################################## +# Check for Existence of Headers # +################################## + +find_path(Julia_MAIN_HEADER julia.h HINTS ${Julia_INCLUDE_DIRS}) + +MESSAGE(STATUS "Julia_LLVM_VERSION: ${Julia_LLVM_VERSION}") + +########################### +# FindPackage Boilerplate # +########################### + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Julia + REQUIRED_VARS Julia_LIBRARY Julia_LIBRARY_DIR Julia_INCLUDE_DIRS Julia_MAIN_HEADER + VERSION_VAR Julia_VERSION_STRING + FAIL_MESSAGE "Julia not found" +) \ No newline at end of file diff --git a/test/heat-images/c-julia/README.md b/test/heat-images/c-julia/README.md new file mode 100644 index 0000000..2b50cf6 --- /dev/null +++ b/test/heat-images/c-julia/README.md @@ -0,0 +1,3 @@ +# gRPC server in C++ calling Julia methods + +> Does not work, it segmentation faults after a few calls. diff --git a/test/heat-images/c-julia/demo.ipynb b/test/heat-images/c-julia/demo.ipynb new file mode 100644 index 0000000..c27ba77 --- /dev/null +++ b/test/heat-images/c-julia/demo.ipynb @@ -0,0 +1,175 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "import grpc\n", + "from grpc4bmi.bmi_grpc_client import BmiClient\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "mymodel = BmiClient(grpc.insecure_channel(\"localhost:55555\"))" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mymodel.initialize(\"/usr/local/share/heat.toml\")" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'The 2D Heat Equation'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mymodel.get_component_name()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "ename": "_InactiveRpcError", + "evalue": "<_InactiveRpcError of RPC that terminated with:\n\tstatus = StatusCode.UNAVAILABLE\n\tdetails = \"Socket closed\"\n\tdebug_error_string = \"UNKNOWN:Error received from peer ipv4:127.0.0.1:55555 {grpc_message:\"Socket closed\", grpc_status:14, created_time:\"2023-10-18T11:32:54.922549729+02:00\"}\"\n>", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31m_InactiveRpcError\u001b[0m Traceback (most recent call last)", + "\u001b[1;32m/home/verhoes/git/eWaterCycle/grpc4bmi/test/heat-images/c-julia/demo.ipynb Cell 5\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m mymodel\u001b[39m.\u001b[39;49mget_component_name()\n", + "File \u001b[0;32m~/git/eWaterCycle/grpc4bmi/grpc4bmi/bmi_grpc_client.py:125\u001b[0m, in \u001b[0;36mBmiClient.get_component_name\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 123\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mstr\u001b[39m(\u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstub\u001b[39m.\u001b[39mgetComponentName(bmi_pb2\u001b[39m.\u001b[39mEmpty())\u001b[39m.\u001b[39mname)\n\u001b[1;32m 124\u001b[0m \u001b[39mexcept\u001b[39;00m grpc\u001b[39m.\u001b[39mRpcError \u001b[39mas\u001b[39;00m e:\n\u001b[0;32m--> 125\u001b[0m handle_error(e)\n", + "File \u001b[0;32m~/git/eWaterCycle/grpc4bmi/grpc4bmi/bmi_grpc_client.py:123\u001b[0m, in \u001b[0;36mBmiClient.get_component_name\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 121\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39mget_component_name\u001b[39m(\u001b[39mself\u001b[39m):\n\u001b[1;32m 122\u001b[0m \u001b[39mtry\u001b[39;00m:\n\u001b[0;32m--> 123\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mstr\u001b[39m(\u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mstub\u001b[39m.\u001b[39;49mgetComponentName(bmi_pb2\u001b[39m.\u001b[39;49mEmpty())\u001b[39m.\u001b[39mname)\n\u001b[1;32m 124\u001b[0m \u001b[39mexcept\u001b[39;00m grpc\u001b[39m.\u001b[39mRpcError \u001b[39mas\u001b[39;00m e:\n\u001b[1;32m 125\u001b[0m handle_error(e)\n", + "File \u001b[0;32m~/git/eWaterCycle/grpc4bmi/.venv/lib/python3.10/site-packages/grpc/_channel.py:946\u001b[0m, in \u001b[0;36m_UnaryUnaryMultiCallable.__call__\u001b[0;34m(self, request, timeout, metadata, credentials, wait_for_ready, compression)\u001b[0m\n\u001b[1;32m 937\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__call__\u001b[39m(\u001b[39mself\u001b[39m,\n\u001b[1;32m 938\u001b[0m request,\n\u001b[1;32m 939\u001b[0m timeout\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m,\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 942\u001b[0m wait_for_ready\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m,\n\u001b[1;32m 943\u001b[0m compression\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m):\n\u001b[1;32m 944\u001b[0m state, call, \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m_blocking(request, timeout, metadata, credentials,\n\u001b[1;32m 945\u001b[0m wait_for_ready, compression)\n\u001b[0;32m--> 946\u001b[0m \u001b[39mreturn\u001b[39;00m _end_unary_response_blocking(state, call, \u001b[39mFalse\u001b[39;49;00m, \u001b[39mNone\u001b[39;49;00m)\n", + "File \u001b[0;32m~/git/eWaterCycle/grpc4bmi/.venv/lib/python3.10/site-packages/grpc/_channel.py:849\u001b[0m, in \u001b[0;36m_end_unary_response_blocking\u001b[0;34m(state, call, with_call, deadline)\u001b[0m\n\u001b[1;32m 847\u001b[0m \u001b[39mreturn\u001b[39;00m state\u001b[39m.\u001b[39mresponse\n\u001b[1;32m 848\u001b[0m \u001b[39melse\u001b[39;00m:\n\u001b[0;32m--> 849\u001b[0m \u001b[39mraise\u001b[39;00m _InactiveRpcError(state)\n", + "\u001b[0;31m_InactiveRpcError\u001b[0m: <_InactiveRpcError of RPC that terminated with:\n\tstatus = StatusCode.UNAVAILABLE\n\tdetails = \"Socket closed\"\n\tdebug_error_string = \"UNKNOWN:Error received from peer ipv4:127.0.0.1:55555 {grpc_message:\"Socket closed\", grpc_status:14, created_time:\"2023-10-18T11:32:54.922549729+02:00\"}\"\n>" + ] + } + ], + "source": [ + "mymodel.get_component_name()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'The 2D Heat Equation'" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mymodel.get_component_name()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from time import sleep" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + ".\r" + ] + }, + { + "ename": "AttributeError", + "evalue": "module 'os' has no attribute 'sleep'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "\u001b[1;32m/home/verhoes/git/eWaterCycle/grpc4bmi/test/heat-images/c-julia/demo.ipynb Cell 8\u001b[0m line \u001b[0;36m3\n\u001b[1;32m 1\u001b[0m \u001b[39mfor\u001b[39;00m _ \u001b[39min\u001b[39;00m \u001b[39mrange\u001b[39m(\u001b[39m100\u001b[39m):\n\u001b[1;32m 2\u001b[0m \u001b[39mprint\u001b[39m(\u001b[39m'\u001b[39m\u001b[39m.\u001b[39m\u001b[39m'\u001b[39m, end\u001b[39m=\u001b[39m\u001b[39m'\u001b[39m\u001b[39m\\r\u001b[39;00m\u001b[39m'\u001b[39m)\n\u001b[0;32m----> 3\u001b[0m os\u001b[39m.\u001b[39;49msleep(\u001b[39m0.01\u001b[39m)\n\u001b[1;32m 4\u001b[0m mymodel\u001b[39m.\u001b[39mget_component_name()\n", + "\u001b[0;31mAttributeError\u001b[0m: module 'os' has no attribute 'sleep'" + ] + } + ], + "source": [ + "for _ in range(100):\n", + " print('.', end='\\r')\n", + " sleep(0.01)\n", + " mymodel.get_component_name()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/test/heat-images/c-julia/run_bmi_server.cc b/test/heat-images/c-julia/run_bmi_server.cc new file mode 100644 index 0000000..123273f --- /dev/null +++ b/test/heat-images/c-julia/run_bmi_server.cc @@ -0,0 +1,290 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include "bmi_grpc_server.h" + +using namespace std; + +class NotImplemented : public std::logic_error +{ +public: + NotImplemented() : std::logic_error("Not Implemented"){}; +}; + +void handle_julia_exception(void) { + if (jl_value_t *ex = jl_exception_occurred()) { + jl_printf(jl_stderr_stream(), "Exception:\n"); + jl_call2( + jl_get_function(jl_base_module, "showerror"), + jl_stderr_obj(), + ex + ); + jl_printf(jl_stderr_stream(), "\nI quit!\n"); + jl_atexit_hook(1); + exit(1); + } +} + +class BmiJulia : public bmi::Bmi +{ +public: + BmiJulia(std::string package_name, std::string model_name) + { + this->modelt = model_name; + this->package_name = package_name; + } + void Initialize(std::string config_file) + { + jl_init(); + jl_eval_string("import BasicModelInterface as BMI"); + handle_julia_exception(); + std::string import = "import " + this->package_name; + // cout << import << endl; + jl_eval_string(import.c_str()); + handle_julia_exception(); + + std::string cmd = "model = BMI.initialize("; + cmd.append(this->modelt); + cmd.append(", \""); + cmd.append(config_file); + cmd.append("\")"); + // cout << cmd << endl; + jl_eval_string(cmd.c_str()); + handle_julia_exception(); + std::cout << "Initialize has Thread ID: " << std::this_thread::get_id() << std::endl; + + } + + + void Update() + { + throw NotImplemented(); + } + void UpdateUntil(double time) + { + throw NotImplemented(); + } + void Finalize() + { + throw NotImplemented(); + } + + std::string GetComponentName() + { + std::cout << "GetComponentName has Thread ID: " << std::this_thread::get_id() << std::endl; + cout << "GetComponentName" << jl_is_initialized << endl; + jl_eval_string("print(model)"); + // If we cant get passed line above + // then we are running in another thread + // that initialize has not run in + handle_julia_exception(); + std::string cmd = "BMI.get_component_name(model)"; + cout << cmd << endl; + jl_value_t *jname = jl_eval_string(cmd.c_str()); + handle_julia_exception(); + const char* cname = jl_string_ptr(jname); + handle_julia_exception(); + std::string name(cname); + return name; + } + int GetInputItemCount() + { + throw NotImplemented(); + } + int GetOutputItemCount() + { + throw NotImplemented(); + } + std::vector GetInputVarNames() + { + throw NotImplemented(); + } + std::vector GetOutputVarNames() + { + throw NotImplemented(); + } + + int GetVarGrid(std::string name) + { + throw NotImplemented(); + } + std::string GetVarType(std::string name) + { + throw NotImplemented(); + } + int GetVarItemsize(std::string name) + { + throw NotImplemented(); + } + std::string GetVarUnits(std::string name) + { + throw NotImplemented(); + } + int GetVarNbytes(std::string name) + { + throw NotImplemented(); + } + std::string GetVarLocation(std::string name) + { + throw NotImplemented(); + } + + double GetCurrentTime() + { + throw NotImplemented(); + } + double GetStartTime() + { + throw NotImplemented(); + } + double GetEndTime() + { + throw NotImplemented(); + } + std::string GetTimeUnits() + { + throw NotImplemented(); + } + double GetTimeStep() + { + throw NotImplemented(); + } + + void GetValue(std::string name, void *dest) + { + throw NotImplemented(); + } + void *GetValuePtr(std::string name) + { + throw NotImplemented(); + } + void GetValueAtIndices(std::string name, void *dest, int *inds, int count) + { + throw NotImplemented(); + } + + void SetValue(std::string name, void *src) + { + throw NotImplemented(); + } + void SetValueAtIndices(std::string name, int *inds, int len, void *src) + { + throw NotImplemented(); + } + + int GetGridRank(const int grid) + { + throw NotImplemented(); + } + int GetGridSize(const int grid) + { + throw NotImplemented(); + } + std::string GetGridType(const int grid) + { + throw NotImplemented(); + } + + void GetGridShape(const int grid, int *shape) + { + throw NotImplemented(); + } + void GetGridSpacing(const int grid, double *spacing) + { + throw NotImplemented(); + } + void GetGridOrigin(const int grid, double *origin) + { + throw NotImplemented(); + } + + void GetGridX(const int grid, double *x) + { + throw NotImplemented(); + } + void GetGridY(const int grid, double *y) + { + throw NotImplemented(); + } + void GetGridZ(const int grid, double *z) + { + throw NotImplemented(); + } + + int GetGridNodeCount(const int grid) + { + throw NotImplemented(); + } + int GetGridEdgeCount(const int grid) + { + throw NotImplemented(); + } + int GetGridFaceCount(const int grid) + { + throw NotImplemented(); + } + + void GetGridEdgeNodes(const int grid, int *edge_nodes) + { + throw NotImplemented(); + } + void GetGridFaceEdges(const int grid, int *face_edges) + { + throw NotImplemented(); + } + void GetGridFaceNodes(const int grid, int *face_nodes) + { + throw NotImplemented(); + } + void GetGridNodesPerFace(const int grid, int *nodes_per_face) + { + throw NotImplemented(); + } + +private: + std::string modelt; + std::string package_name; +}; + +int main(int argc, char *argv[]) +{ + { + { + // // Simple running Julia code + // jl_eval_string("x = sqrt(2.0)"); + // jl_eval_string("print(x)"); + } + { + + /* + In Julia repl: + + import BasicModelInterface as BMI + import Heat + # https://github.com/csdms/bmi-example-julia/raw/main/example/heat.toml + model = BMI.initialize(Heat.Model, "/usr/local/share/heat.toml") + BMI.get_component_name(model) + + */ + } + { + // TODO read BmiJulia from env var or cli arguments + bmi::Bmi* model = new BmiJulia("Heat", "Heat.Model"); + // Calling without grpc works + // model->Initialize("/usr/local/share/heat.toml"); + // cout << model->GetComponentName() << endl; + + // Calling from grpc client causes segfault when calling jl_eval_string with BMI.initialize() + run_bmi_server(model, argc, argv); + } + } + int ret = 0; + jl_atexit_hook(ret); + return ret; +} \ No newline at end of file diff --git a/test/test_julia.py b/test/test_julia.py new file mode 100644 index 0000000..17eef7c --- /dev/null +++ b/test/test_julia.py @@ -0,0 +1,164 @@ +from pathlib import Path +from textwrap import dedent +import numpy as np +from numpy.testing import assert_array_equal +import pytest + + +try: + from grpc4bmi.bmi_julia_model import BmiJulia + from juliacall import Main as jl +except ImportError: + BmiJulia = None + +@pytest.mark.skipif(not BmiJulia, reason="Julia and its dependencies are not installed") +class TestJuliaHeatModel: + @pytest.fixture(scope="class", autouse=True) + def install_heat(self): + # TODO for other Julia models do we need to install BasicModelInterface? + # it is dep of Heat.jl, but we use it directly + jl.Pkg.add('BasicModelInterface') + jl.Pkg.add( + url="https://github.com/csdms/bmi-example-julia.git", + rev="80c34b4f2217599e600fe9372b1bae50e1229edf", + ) + + + @pytest.fixture + def cfg_file(self, tmp_path: Path): + fn = tmp_path / "heat.toml" + fn.write_text( + dedent( + """\ + # Heat model configuration + shape = [6, 8] + spacing = [1.0, 1.0] + origin = [0.0, 0.0] + alpha = 1.0 + """ + ) + ) + return fn + + @pytest.fixture + def model(self, cfg_file): + model = BmiJulia.from_name("Heat.Model") + model.initialize(str(cfg_file)) + return model + + @pytest.mark.parametrize( + "fn_name,fn_args,expected", + [ + ("get_component_name", tuple(), "The 2D Heat Equation"), + ("get_input_item_count", tuple(), 1), + ("get_output_item_count", tuple(), 1), + ("get_input_var_names", tuple(), ["plate_surface__temperature"]), + ("get_output_var_names", tuple(), ["plate_surface__temperature"]), + ("get_start_time", tuple(), 0.0), + ("get_end_time", tuple(), np.Inf), + ("get_time_step", tuple(), 0.25), + ("get_time_units", tuple(), "s"), + ("get_var_type", ["plate_surface__temperature"], "float64"), + ("get_var_units", ["plate_surface__temperature"], "K"), + ("get_var_itemsize", ["plate_surface__temperature"], 8), + ("get_var_nbytes", ["plate_surface__temperature"], 384), + ("get_var_grid", ["plate_surface__temperature"], 0), + ("get_var_location", ["plate_surface__temperature"], "node"), + # result of get_grid_shape is incompatible with spec, + # as it says order should be y,x not x,y + ("get_grid_shape", [0, np.zeros((2,))], [6, 8]), + ("get_grid_spacing", [0, np.zeros((2,))], [1.0, 1.0]), + ("get_grid_origin", [0, np.zeros((2,))], [0.0, 0.0]), + ("get_grid_rank", [0], 2), + ("get_grid_size", [0], 48), + ("get_grid_type", [0], "uniform_rectilinear"), + ("get_grid_node_count", [0], 48), + ("update", tuple(), None), + ("update_until", [2], None), + ("finalize", tuple(), None), + ("get_current_time", tuple(), 0.0), + ], + ) + def test_methods(self, model: BmiJulia, fn_name, fn_args, expected): + fn = getattr(model, fn_name) + if fn_args == tuple(): + result = fn() + else: + result = fn(*fn_args) + + try: + assert_array_equal(result, expected) + except: + assert result == expected + + def test_get_value(self, model: BmiJulia): + result = model.get_value("plate_surface__temperature", np.zeros((48,))) + assert result.shape == (48,) + assert result.dtype == np.float64 + # cannot test values as model is initialized with random values + + def test_get_value_ptr(self, model: BmiJulia): + with pytest.raises(NotImplementedError): + model.get_value_ptr("plate_surface__temperature") + + def test_get_value_at_indices(self, model: BmiJulia): + result = model.get_value_at_indices( + "plate_surface__temperature", np.zeros((3,)), np.array([5, 6, 7]) + ) + assert result.shape == (3,) + assert result.dtype == np.float64 + # cannot test values as model is initialized with random values + + def test_set_value(self, model: BmiJulia): + model.set_value("plate_surface__temperature", np.ones((48,))) + + result = model.get_value("plate_surface__temperature", np.zeros((48,))) + assert_array_equal(result, np.ones((48,))) + + def test_set_value_at_indices(self, model: BmiJulia): + model.set_value_at_indices( + "plate_surface__temperature", np.array([5, 6, 7]), np.ones((3,)) + ) + + result = model.get_value("plate_surface__temperature", np.zeros((48,))) + assert_array_equal(result[5:8], np.ones((3,))) + +# Heat.jl does not implement all methods, use fake.jl to test all methods not covered by Heat.jl +@pytest.mark.skipif(not BmiJulia, reason="Julia and its dependencies are not installed") +class TestJuliaFakeModel: + @pytest.fixture(scope="class", autouse=True) + def install_fake(self): + jl.Pkg.add('BasicModelInterface') + jl.seval('include("test/fake.jl")') + + @pytest.fixture + def model(self): + model = BmiJulia.from_name("Main.FakeModel.Model", "Main.FakeModel.BMI") + model.initialize('') + return model + + @pytest.mark.parametrize( + "fn_name,fn_args,expected", + [ + ("get_grid_x", [0, np.zeros((2,))], [1, 2]), + ("get_grid_y", [0, np.zeros((2,))], [3, 4]), + ("get_grid_z", [0, np.zeros((2,))], [5, 6]), + ('get_grid_edge_count', [0], 10), + ('get_grid_face_count', [0], 11), + ('get_grid_edge_nodes',[0, np.zeros((2,))], [7, 8]), + ('get_grid_face_edges',[0, np.zeros((2,))], [9,10]), + ('get_grid_face_nodes',[0, np.zeros((2,))], [11, 12]), + ('get_grid_nodes_per_face',[0, np.zeros((2,))], [13, 14]), + ], + ) + def test_methods(self, model: BmiJulia, fn_name, fn_args, expected): + fn = getattr(model, fn_name) + if fn_args == tuple(): + result = fn() + else: + result = fn(*fn_args) + + try: + assert_array_equal(result, expected) + except: + assert result == expected \ No newline at end of file diff --git a/test/test_subproc.py b/test/test_subproc.py index 7a0e20f..e57b6a2 100644 --- a/test/test_subproc.py +++ b/test/test_subproc.py @@ -22,7 +22,7 @@ def make_bmi_classes(init=False): numpy.random.seed(0) os.environ["PYTHONPATH"] = os.path.dirname(os.path.abspath(__file__)) - client = BmiClientSubProcess("heat.BmiHeat") + client = BmiClientSubProcess("heat.BmiHeat", timeout=10, delay=3) local = BmiHeat() if init: client.initialize(None)