Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementation of the STEMMUS_SCOPE BMI, +docs +gprc4bmi #89

Merged
merged 44 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1e3011d
First implementation of the STEMMUS_SCOPE BMI
BSchilperoort Nov 17, 2023
facb2b3
Use STEMMUS_SCOPE's interactive mode, h5py for IO
BSchilperoort Nov 22, 2023
5f7f418
Fix type errors, comply with linters & formatter
BSchilperoort Nov 22, 2023
bdc3e3f
Implement all required BMI methods
BSchilperoort Nov 28, 2023
4de0a59
Add a notebook demonstrating the BMI
BSchilperoort Nov 28, 2023
fc2438d
Fix update_until bug in BMI
BSchilperoort Nov 28, 2023
8576160
Make type hints compatible with py 3.8
BSchilperoort Nov 29, 2023
87a2b0d
Add set/get value at indices methods
BSchilperoort Nov 29, 2023
0226af0
Apply code formatting
BSchilperoort Nov 29, 2023
aa6dd0d
Add support for dockerized model.
BSchilperoort Jan 4, 2024
902b1f8
Add docker as optional dependency
BSchilperoort Jan 10, 2024
e806224
Add support for exe file from env. variable.
BSchilperoort Jan 10, 2024
4d5eb7c
Fix volume binds & root file issue. Check image tags.
BSchilperoort Jan 10, 2024
98fbcff
WIP Dockerfile for grpc4bmi
BSchilperoort Jan 10, 2024
93066b7
Add start on BMI documentation
BSchilperoort Jan 10, 2024
88ec40b
Remove Docker container upon clean finalize
BSchilperoort Jan 11, 2024
71c06aa
Move BMI code to bmi module
BSchilperoort Jan 11, 2024
cb95e3c
Deprecate Py3.8. Add support for Py3.11
BSchilperoort Jan 11, 2024
d7f39bd
Fix typing and formatting issues
BSchilperoort Jan 11, 2024
7fa1aaa
Add h5py to dependencies
BSchilperoort Jan 11, 2024
4e1098d
Update netcdf4 version pin
BSchilperoort Jan 11, 2024
c439178
Split of docker utils. Automagically pull docker image
BSchilperoort Jan 11, 2024
c2a101a
Remove unneeded docker.errors import
BSchilperoort Jan 11, 2024
71f8cb8
Make ruff happy
BSchilperoort Jan 11, 2024
1bbd352
Set GID correctly when starting container.
BSchilperoort Jan 23, 2024
310e03c
Ensure state is writable
BSchilperoort Jan 23, 2024
828b69b
Add matplotlib to dependencies
BSchilperoort Jan 23, 2024
eb4d385
Make Docker and local process communication more robust
BSchilperoort Jan 25, 2024
daf0910
Add BMI tests w/ Docker (linux only on GH Actions)
BSchilperoort Jan 25, 2024
3057ea5
Exclude untestable parts from coverage
BSchilperoort Jan 25, 2024
f8e38b4
Rename test file to test_bmi_docker. Add first batch of tests.
BSchilperoort Jan 25, 2024
a803209
Expand BMI tests, fix BMI bugs.
BSchilperoort Jan 26, 2024
93ce52a
Pin black to previous stable version
BSchilperoort Jan 26, 2024
b0ca6fd
Additional tests for BMI error messages
BSchilperoort Jan 26, 2024
09a52c1
Remove unused import
BSchilperoort Jan 26, 2024
1667729
Add tests for tag validation
BSchilperoort Jan 26, 2024
b705c6d
Update BMI documentation, add to mkdocs
BSchilperoort Jan 26, 2024
0e98061
Fix links to BMI notebook
BSchilperoort Jan 26, 2024
787103d
Add gprc4bmi dockerfile, instructions/docs.
BSchilperoort Jan 26, 2024
fbc7818
Correct grpc typo
BSchilperoort Jan 26, 2024
b0a7ff8
Also correct notebook name
BSchilperoort Jan 26, 2024
05300bd
Improve reading of stdout
BSchilperoort Jan 30, 2024
84a7f21
Add more explanation to the grpc demo
BSchilperoort Jan 30, 2024
95d4188
Improve the documentation on the docker images/files.
BSchilperoort Jan 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
510 changes: 510 additions & 0 deletions PyStemmusScope/bmi.py

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions PyStemmusScope/bmi_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Utilities for the STEMMUS_SCOPE Basic Model Interface."""
import numpy as np


INAPPLICABLE_GRID_METHOD_MSG = (
"This grid method is not implmented for the STEMMUS_SCOPE BMI because the model is"
"\non a rectilinear grid."
)


class InapplicableBmiMethods:
"""Holds methods that are not applicable for STEMMUS_SCOPE's rectilinear grid."""

def get_grid_spacing(self, grid: int, spacing: np.ndarray) -> np.ndarray:
"""Get distance between nodes of the computational grid."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_origin(self, grid: int, origin: np.ndarray) -> np.ndarray:
"""Get coordinates for the lower-left corner of the computational grid."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_var_location(self, name: str) -> str:
"""Get the grid element type that the a given variable is defined on."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_node_count(self, grid: int) -> int:
"""Get the number of nodes in the grid."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_edge_count(self, grid: int) -> int:
"""Get the number of edges in the grid."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_face_count(self, grid: int) -> int:
"""Get the number of faces in the grid."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_edge_nodes(self, grid: int, edge_nodes: np.ndarray) -> np.ndarray:
"""Get the edge-node connectivity."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_face_edges(self, grid: int, face_edges: np.ndarray) -> np.ndarray:
"""Get the face-edge connectivity."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_face_nodes(self, grid: int, face_nodes: np.ndarray) -> np.ndarray:
"""Get the face-node connectivity."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)

def get_grid_nodes_per_face(
self, grid: int, nodes_per_face: np.ndarray
) -> np.ndarray:
"""Get the number of nodes for each face."""
raise NotImplementedError(INAPPLICABLE_GRID_METHOD_MSG)
112 changes: 112 additions & 0 deletions PyStemmusScope/docker_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""The Docker STEMMUS_SCOPE model process wrapper."""
from PyStemmusScope.config_io import read_config
from pathlib import Path
import os
import docker


def make_docker_vols_binds(cfg_file: str) -> tuple[list[str], list[str]]:
"""Make docker volume mounting configs.

Args:
cfg_file: Location of the config file

Returns:
volumes, binds
"""
cfg = read_config(cfg_file)

volumes = [cfg["OutputPath"], cfg["InputPath"]]
binds = [
f"{cfg['OutputPath']}:{cfg['OutputPath']}:rw",
f"{cfg['InputPath']}:{cfg['InputPath']}:ro",
]

if (
not Path(cfg_file).parent.is_relative_to(cfg["InputPath"]) or
SarahAlidoost marked this conversation as resolved.
Show resolved Hide resolved
not Path(cfg_file).parent.is_relative_to(cfg["OutputPath"])
):
cfg_folder = str(Path(cfg_file).parent)
volumes.append(cfg_folder)
binds.append(f"{cfg_folder}:{cfg_folder}:ro")

return volumes, binds


class StemmusScopeDocker:
"""Communicate with a STEMMUS_SCOPE Docker container."""
# The image is hard coded here to ensure compatiblity:
image = "ghcr.io/ecoextreml/stemmus_scope:1.5.0"
SarahAlidoost marked this conversation as resolved.
Show resolved Hide resolved

_process_ready_phrase = b"Select BMI mode:"

def __init__(self, cfg_file: str):
"""Create the Docker container.."""
self.cfg_file = cfg_file

self.client = docker.APIClient()
BSchilperoort marked this conversation as resolved.
Show resolved Hide resolved

vols, binds = make_docker_vols_binds(cfg_file)
self.container_id = self.client.create_container(
self.image,
stdin_open=True,
tty=True,
detach=True,
volumes=vols,
host_config=self.client.create_host_config(binds=binds)
)

self.running = False

def wait_for_model(self):
"""Wait for the model to be ready to receive (more) commands."""
output = b""

while self._process_ready_phrase not in output:
data = self.socket.read(1)
if data is None:
msg = "Could not read data from socket. Docker container might be dead."
raise ConnectionError(msg)
else:
output += bytes(data)

def is_alive(self):
"""Return if the process is alive."""
return self.running

def initialize(self):
"""Initialize the model and wait for it to be ready."""
if self.is_alive():
self.client.stop(self.container_id)
BSchilperoort marked this conversation as resolved.
Show resolved Hide resolved

self.client.start(self.container_id)
self.socket = self.client.attach_socket(
self.container_id, {'stdin': 1, 'stdout': 1, 'stream':1}
)
self.wait_for_model()
os.write(
self.socket.fileno(),
bytes(f'initialize "{self.cfg_file}"\n', encoding="utf-8")
)
self.wait_for_model()

self.running = True

def update(self):
"""Update the model and wait for it to be ready."""
if self.is_alive():
os.write(
self.socket.fileno(),
b'update\n'
)
self.wait_for_model()
else:
msg = "Docker container is not alive. Please restart the model."
raise ConnectionError(msg)

def finalize(self):
"""Finalize the model."""
if self.is_alive():
os.write(self.socket.fileno(),b'finalize\n')
else:
pass
8 changes: 4 additions & 4 deletions PyStemmusScope/global_data/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ def assert_time_within_bounds(
raise MissingDataError(
"\nThe available data cannot cover the specified start and end time.\n"
f" Specified model time range:\n"
f" {np.datetime_as_string(start_time, unit='m')}"
f" - {np.datetime_as_string(end_time, unit='m')}\n"
f" Data start: {np.datetime_as_string(data[time_dim].min(), unit='m')}\n"
f" Data end: {np.datetime_as_string(data[time_dim].max(), unit='m')}"
f" {np.datetime_as_string(start_time, unit='m')}" # type: ignore
f" - {np.datetime_as_string(end_time, unit='m')}\n" # type: ignore
f" Data start: {np.datetime_as_string(data[time_dim].min(), unit='m')}\n" # type: ignore
f" Data end: {np.datetime_as_string(data[time_dim].max(), unit='m')}" # type: ignore
)


Expand Down
80 changes: 80 additions & 0 deletions PyStemmusScope/local_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""The local STEMMUS_SCOPE model process wrapper."""
import subprocess
from typing import Union
from PyStemmusScope.config_io import read_config
import os


def is_alive(process: Union[subprocess.Popen, None]) -> subprocess.Popen:
"""Return process if the process is alive, raise an exception if it is not."""
if process is None:
msg = "Model process does not seem to be open."
raise ConnectionError(msg)
if process.poll() is not None:
msg = f"Model terminated with return code {process.poll()}"
raise ConnectionError(msg)
return process


def wait_for_model(process: subprocess.Popen, phrase=b"Select BMI mode:") -> None:
"""Wait for model to be ready for interaction."""
output = b""
while is_alive(process) and phrase not in output:
assert process.stdout is not None # required for type narrowing.
output += bytes(process.stdout.read(1))


class LocalStemmusScope:
"""Communicate with the local STEMMUS_SCOPE executable file."""
def __init__(self, cfg_file: str) -> None:
"""Initialize the process."""
self.cfg_file = cfg_file
config = read_config(cfg_file)

exe_file = config["ExeFilePath"]
args = [exe_file, cfg_file, "bmi"]

os.environ["MATLAB_LOG_DIR"] = str(config["InputPath"])

self.matlab_process = subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
bufsize=0,
)

wait_for_model(self.matlab_process)

def is_alive(self) -> bool:
"""Return if the process is alive."""
try:
is_alive(self.matlab_process)
return True
except ConnectionError:
return False

def initialize(self) -> None:
"""Initialize the model and wait for it to be ready."""
self.matlab_process = is_alive(self.matlab_process)
self.matlab_process.stdin.write(
bytes(f'initialize "{self.cfg_file}"\n', encoding="utf-8") # type: ignore
)
wait_for_model(self.matlab_process)


def update(self) -> None:
"""Update the model and wait for it to be ready."""
if self.matlab_process is None:
msg = "Run initialize before trying to update the model."
raise AttributeError(msg)

self.matlab_process = is_alive(self.matlab_process)
self.matlab_process.stdin.write(b"update\n") # type: ignore
wait_for_model(self.matlab_process)


def finalize(self) -> None:
"""Finalize the model."""
self.matlab_process = is_alive(self.matlab_process)
self.matlab_process.stdin.write(b"finalize\n") # type: ignore
wait_for_model(self.matlab_process, phrase=b"Finished clean up.")
Loading
Loading