From b913f8d6d4971f349661225e93a0e1ae908e356c Mon Sep 17 00:00:00 2001 From: Stefan Verhoeven Date: Fri, 29 Sep 2023 16:26:49 +0200 Subject: [PATCH] Add Julia support to gprc4bmi Refs #101 --- README.md | 29 +- docs/container/building.rst | 24 ++ docs/server/Julia.rst | 47 +++ docs/server/index.rst | 1 + grpc4bmi/bmi_julia_model.py | 615 ++++++++++++++++++++++++++++++++++++ grpc4bmi/run_server.py | 14 + pyproject.toml | 1 + 7 files changed, 727 insertions(+), 4 deletions(-) create mode 100644 docs/server/Julia.rst create mode 100644 grpc4bmi/bmi_julia_model.py diff --git a/README.md b/README.md index f6622cd..06f8e98 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,25 @@ 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 as a server with + +```bash +run-bmi-server --lang julia --name ,, --port +``` + +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. +python3 -c 'from grpc4bmi.bmi_julia_model import install;install("Wflow")' +# Run the server +run-bmi-server --lang julia --name Wflow,Wflow.BMI,Wflow.Model --port 55555 +``` + ### 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 +179,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..918ac44 100644 --- a/docs/container/building.rst +++ b/docs/container/building.rst @@ -68,6 +68,30 @@ 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 +----- + +The docker file for the model container simply contains the installation instructions of grpc4bmi and the BMI-enabled model itself, and as entrypoint the ``run-bmi-server`` command. For the :ref:`python example ` the Docker file will read + +.. code-block:: Dockerfile + + FROM ubuntu:jammy + MAINTAINER your name + + # Install grpc4bmi + RUN pip install grpc4bmi + + # Install your BMI model: + python3 -c 'from grpc4bmi.bmi_julia_model import install;install("")' + + # Run bmi server + ENTRYPOINT ["run-bmi-server", "--lang", "julia", "--name", ",,"] + + # Expose the magic grpc4bmi port + EXPOSE 55555 + +The port 55555 is the internal port in the Docker container that the model communicates over. It is the default port for ``run_bmi_server`` and also the default port that all clients listen to. + C/C++/Fortran ------------- diff --git a/docs/server/Julia.rst b/docs/server/Julia.rst new file mode 100644 index 0000000..35449b3 --- /dev/null +++ b/docs/server/Julia.rst @@ -0,0 +1,47 @@ +Julia +===== + +Grpc4bmi allows you to wrap a Hydrological model written in the `Julia language`_ into a GRPC server. + +.. _Julia language: https://julialang.org/ + +Creating +-------- + +The model should implement `BasicModelInterface.jl`_. + +.. _BasicModelInterface.jl: https://github.com/Deltares/BasicModelInterface.jl + +See `Wflow.jl`_ for an example. + +.. _Wflow.jl: https://deltares.github.io/Wflow.jl/dev/ + +Running +------- + +Once the model has an BMI interface it can be run as a GRPC server by installing the `grpc4bmi[julia]` Python package with + +.. code-block:: bash + + pip install grpc4bmi[julia] + +The model Julia package must be installed in the Julia environment managed by juliacall, +for Wflow use + +.. code-block:: bash + + python3 -c 'from grpc4bmi.bmi_julia_model import install;install("Wflow")' + +The server can be started with + +.. code-block:: sh + + run-bmi-server --lang julia --name ,, --port + +For example with [Wflow.jl](https://github.com/Deltares/Wflow.jl/) use + +.. code-block:: sh + + run-bmi-server --lang julia --name Wflow,Wflow.BMI,Wflow.Model --port 55555 + +The Python grpc4bmi :ref:`usage` can then be used to connect to the server. diff --git a/docs/server/index.rst b/docs/server/index.rst index 20eb970..5f40a1f 100644 --- a/docs/server/index.rst +++ b/docs/server/index.rst @@ -7,4 +7,5 @@ Creating a BMI server python R + Julia Cpp diff --git a/grpc4bmi/bmi_julia_model.py b/grpc4bmi/bmi_julia_model.py new file mode 100644 index 0000000..94b02b6 --- /dev/null +++ b/grpc4bmi/bmi_julia_model.py @@ -0,0 +1,615 @@ +from typing import List + +from bmipy import Bmi +import numpy as np +from juliacall import Main as jl + +def install(package): + """Add package to Julia environment. + + Args: + package: Name of package to install. + """ + jl.Pkg.add(package) + +class BmiJulia(Bmi): + """Python Wrapper of a Julia based implementation of BasicModelInterface. + + BasicModelInterface is available in https://github.com/Deltares/BasicModelInterface.jl repo. + + Args: + package: Name of Julia package which contains interface and model classes + implementation_name: Name of Julia variable which implements BasicModelInterface + model_name: Name of Julia model class + + """ + def __init__(self, package, implementation_name, model_name): + self.module = package + self.model_name = model_name + jl.seval("using " + package) + self.model = getattr(getattr(jl, self.module), self.model_name) + self.implementation = getattr(getattr(jl, package), implementation_name) + + 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) + + 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) -> 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). + """ + return np.array(self.implementation.get_value(self.state, name)) + + 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, 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. + """ + if np.any(inds == 0): + raise ValueError( + "Julia indices start at 1. Please adjust your indices accordingly." + ) + + return np.array( + self.implementation.get_value_at_indices( + self.state, name, jl.convert(jl.Vector[jl.Int64], inds) + ) + ) + + def set_value(self, name: str, values: 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, values)) + + 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[jl.Int64], inds), + 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) -> 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. + """ + return np.array(self.implementation.get_grid_shape(self.state, grid)) + + def get_grid_spacing(self, grid: int) -> 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. + """ + return np.array(self.implementation.get_grid_spacing(self.state, grid)) + + def get_grid_origin(self, grid: int) -> 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. + """ + return np.array(self.implementation.get_grid_origin(self.state, grid)) + + # Non-uniform rectilinear, curvilinear + def get_grid_x(self, grid: int) -> np.ndarray: + """Get coordinates of grid nodes in the x direction. + Parameters + ---------- + grid : int + A grid identifier. + + Returns + ------- + ndarray of float + The input numpy array that holds the grid's column x-coordinates. + """ + return np.array(self.implementation.get_grid_x(self.state, grid)) + + def get_grid_y(self, grid: int) -> np.ndarray: + """Get coordinates of grid nodes in the y direction. + Parameters + ---------- + grid : int + A grid identifier. + + Returns + ------- + ndarray of float + The input numpy array that holds the grid's row y-coordinates. + """ + return np.array(self.implementation.get_grid_y(self.state, grid)) + + def get_grid_z(self, grid: int) -> 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. + """ + return np.array(self.implementation.get_grid_z(self.state, grid)) + + 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) -> np.ndarray: + """Get the edge-node connectivity. + Parameters + ---------- + grid : int + A grid identifier. + + 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. + """ + return np.array(self.implementation.get_grid_edge_nodes(self.state, grid)) + + def get_grid_face_edges(self, grid: int) -> np.ndarray: + """Get the face-edge connectivity. + + Parameters + ---------- + grid : int + A grid identifier. + + Returns + ------- + ndarray of int + A numpy array that holds the face-edge connectivity. + """ + return np.array(self.implementation.get_grid_face_edges(self.state, grid)) + + def get_grid_face_nodes(self, grid: int) -> np.ndarray: + """Get the face-node connectivity. + + Parameters + ---------- + grid : int + A grid identifier. + + 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. + """ + return np.array(self.implementation.get_grid_face_nodes(self.state, grid)) + + def get_grid_nodes_per_face(self, grid: int) -> 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. + """ + return np.array(self.implementation.get_grid_nodes_per_face(self.state, grid)) diff --git a/grpc4bmi/run_server.py b/grpc4bmi/run_server.py index 626f265..8dcdcc5 100755 --- a/grpc4bmi/run_server.py +++ b/grpc4bmi/run_server.py @@ -24,6 +24,11 @@ except ImportError: BmiR = None +try: + from .bmi_julia_model import BmiJulia +except ImportError: + BmiJulia = None + """ Run server script, turning a BMI implementation into an executable by looping indefinitely, until interrupt signals are handled. The command line tool needs at least a module and class name to instantiate the BMI wrapper class that exposes @@ -73,6 +78,11 @@ def build_r(class_name, source_fn): raise ValueError('Missing R dependencies, install with `pip install grpc4bmi[R]') return BmiR(class_name, source_fn) +def build_julia(name: str): + if not BmiJulia: + raise ValueError('Missing Julia dependencies, install with `pip install grpc4bmi[julia]') + module, implementation_name, model_name = name.split(',') + return BmiJulia(module, implementation_name, model_name) def serve(model, port): server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) @@ -110,6 +120,8 @@ def main(argv=sys.argv[1:]): if args.language == "R": model = build_r(args.name, path) + elif args.language == "julia": + model = build_julia(args.name) else: model = build(args.name, path) @@ -142,6 +154,8 @@ def build_parser(): lang_choices = ['python'] if BmiR: lang_choices.append('R') + if BmiJulia: + lang_choices.append('julia') parser.add_argument("--language", default="python", choices=lang_choices, help="Language in which BMI implementation class is written") parser.add_argument("--bmi-version", default="2.0.0", choices=["2.0.0", "0.2"], 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",