diff --git a/.github/workflows/ci.yml b/.github/workflows/openapi.yml similarity index 67% rename from .github/workflows/ci.yml rename to .github/workflows/openapi.yml index d864e65..52ac8c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/openapi.yml @@ -1,12 +1,18 @@ -name: Validate OpenAPI +name: Validate OpenAPI.yaml on: push: paths: - "openapi.yaml" - pull_request: + - .github/workflows/openapi.yml + pull_request_target: paths: - "openapi.yaml" + - .github/workflows/openapi.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: lintspec: @@ -38,19 +44,3 @@ jobs: - name: Yamllint run: yamllint openapi.yaml - - python: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: "pip" - - name: Install ruff - run: pip install ruff - - name: Run ruff - run: ruff check python/remotebmi diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..1b1a754 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,56 @@ +name: Python + +on: + push: + paths: + - "openapi.yaml" + - "python/remotebmi/**" + - "python/tests/**" + - .github/workflows/python.yml + pull_request_target: + paths: + - "openapi.yaml" + - "python/remotebmi/**" + - "python/tests/**" + - .github/workflows/python.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: python + +jobs: + lint-format: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + - name: Install ruff + run: pip install ruff + - name: Run ruff check + run: ruff check + - name: Run ruff format + run: ruff format --check + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + - name: Install dependencies + run: pip install -e .[dev] + - name: Run tests + run: pytest tests \ No newline at end of file diff --git a/.github/workflows/r.yml b/.github/workflows/r.yml new file mode 100644 index 0000000..4d830fc --- /dev/null +++ b/.github/workflows/r.yml @@ -0,0 +1,65 @@ +name: R + +on: + push: + paths: + - "R/remotebmi/**" + - .github/workflows/r.yml + pull_request_target: + paths: + - "R/remotebmi/**" + - .github/workflows/r.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + id-token: write # This is required for requesting the JWT + +jobs: + r: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup R + uses: r-lib/actions/setup-r@v2 + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck, any::covr, any::xml2 + needs: check, coverage + working-directory: R/remotebmi + - uses: r-lib/actions/check-r-package@v2 + with: + working-directory: R/remotebmi + - name: Test coverage + working-directory: R/remotebmi + run: | + cov <- covr::package_coverage( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + covr::to_cobertura(cov) + shell: Rscript {0} + - uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: ${{ github.event_name != 'pull_request' && true || false }} + file: ./R/remotebmi/cobertura.xml + plugin: noop + disable_search: true + use_oidc: true + - name: Show testthat output + if: always() + run: | + ## -------------------------------------------------------------------- + find '${{ runner.temp }}/R/remotebmi/package' -name 'testthat.Rout*' -exec cat '{}' \; || true + shell: bash + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-failures + path: ${{ runner.temp }}/R/remotebmi/package diff --git a/.gitignore b/.gitignore index 5079cc5..f7f5686 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,6 @@ python/heat.toml RemoteBMI.jl/example/Project.toml RemoteBMI.jl/example/heat.toml openapi-generator-cli.jar -openapitools.json \ No newline at end of file +openapitools.json +PEQ_Hupsel.dat +walrus.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c532b2..a7e515f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,11 @@ # Contributing +## AI Assistance + +The documentation/software code in this repository can and has been partly generated and/or refined using +GitHub CoPilot. All AI-output has been verified for correctness, +accuracy and completeness, adapted where needed, and approved by the author. + ## Add another language A directory of the name of the language should be created in the root of the repository. diff --git a/R/remotebmi/.Rbuildignore b/R/remotebmi/.Rbuildignore new file mode 100644 index 0000000..5163d0b --- /dev/null +++ b/R/remotebmi/.Rbuildignore @@ -0,0 +1 @@ +^LICENSE\.md$ diff --git a/R/remotebmi/DESCRIPTION b/R/remotebmi/DESCRIPTION new file mode 100644 index 0000000..7b0f550 --- /dev/null +++ b/R/remotebmi/DESCRIPTION @@ -0,0 +1,21 @@ +Package: remotebmi +Title: BMI over http +Version: 0.0.0.9000 +Authors@R: + person("Stefan", "Verhoeven", , "s.verhoeven@esciencecenter.nl", role = c("aut", "cre"), + comment = c(ORCID = "https://orcid.org/0000-0002-5821-2060")) +Description: Runs a BMI model as a http json web service. +URL: https://github.com/eWaterCycle/remotebmi +License: Apache License (>= 2) +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 +Imports: + fiery, + reqres, + routr +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 +Remotes: + eWaterCycle/bmi-r diff --git a/R/remotebmi/LICENSE.md b/R/remotebmi/LICENSE.md new file mode 100644 index 0000000..b62a9b5 --- /dev/null +++ b/R/remotebmi/LICENSE.md @@ -0,0 +1,194 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/R/remotebmi/NAMESPACE b/R/remotebmi/NAMESPACE new file mode 100644 index 0000000..66ce286 --- /dev/null +++ b/R/remotebmi/NAMESPACE @@ -0,0 +1,3 @@ +# Generated by roxygen2: do not edit by hand + +export(serve) diff --git a/R/remotebmi/R/route.R b/R/remotebmi/R/route.R new file mode 100644 index 0000000..641ce80 --- /dev/null +++ b/R/remotebmi/R/route.R @@ -0,0 +1,449 @@ +last_segment <- function(path) { + # keys values are lowercase at + # https://github.com/thomasp85/routr/blob/8605611a10607016a83660f83f310075787a27b2/R/route.R#L250 # nolint: line_length_linter. + # need untouched version + segments <- unlist(strsplit(path, "/")) + return(segments[length(segments)]) +} + + +#' Create a Route for the Given Model +#' +#' This function generates a route for the specified model. The route is used to +#' facilitate communication and interaction with the model. +#' +#' @param model The model instance to be used in route handlers. +#' Must implement the subclass of +#' [AbstractBmi](https://github.com/eWaterCycle/bmi-r/blob/master/R/abstract-bmi.R) # nolint: line_length_linter. +#' +#' @return A route object that can be used to interact with the model. +#' +#' @examples +#' \dontrun{ +#' model <- SomeModel$new() +#' route <- create_route(model) +#' } +#' +create_route <- function(model) { + bmi_initialize <- function(request, response, keys, ...) { + request$parse(json = reqres::parse_json()) + model$bmi_initialize(request$body$config_file) + response$status <- 201L + return(FALSE) + } + + update <- function(request, response, keys, ...) { + model$update() + response$status <- 204L + return(FALSE) + } + + update_until <- function(request, response, keys, ...) { + request$parse(json = reqres::parse_json()) + time <- request$body + model$update_until(time) + response$status <- 204L + return(FALSE) + } + + finalize <- function(request, response, keys, ...) { + model$bmi_finalize() + response$status <- 204L + return(FALSE) + } + + get_component_name <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- list(name = model$get_component_name()) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_output_var_names <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + names <- model$get_output_var_names() + if (is.null(names)) { + response$body <- "[]" + } else { + response$body <- names + response$format(json = reqres::format_json()) + } + return(FALSE) + } + + get_output_item_count <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_output_item_count() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_input_var_names <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + names <- model$get_input_var_names() + if (is.null(names)) { + response$body <- "[]" + } else { + response$body <- names + response$format(json = reqres::format_json()) + } + return(FALSE) + } + + get_input_item_count <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_input_item_count() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_time_units <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- list(units = model$get_time_units()) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_time_step <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_time_step() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_current_time <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_current_time() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_start_time <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_start_time() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_end_time <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_end_time() + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_grid <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_var_grid(last_segment(request$path)) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_type <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + name <- last_segment(request$path) + rawType <- model$get_var_type(name) # nolint: object_name_linter. + type <- ifelse(rawType == "float64", "double", rawType) + # TODO map other types to double, float, int32 or int64 + response$body <- list(type = type) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_itemsize <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_var_itemsize(last_segment(request$path)) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_units <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + units <- model$get_var_units(last_segment(request$path)) + response$body <- list(units = units) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_nbytes <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_var_nbytes(last_segment(request$path)) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_var_location <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + name <- last_segment(request$path) + response$body <- list(location = model$get_var_location(name)) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_value <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_value(last_segment(request$path)) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_value_at_indices <- function(request, response, keys, ...) { + request$parse(json = reqres::parse_json()) + response$status <- 200L + response$type <- "application/json" + name <- last_segment(request$path) + indices <- request$body + if (!is.integer(indices) && !is.list(indices)) { + response$status <- 400L + response$body <- list(title = "Request body must be an array") + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } else if (length(indices) == 0) { + response$status <- 400L + response$body <- list(title = "Request body must be a non-empty list") + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } else if (any(indices < 0)) { + response$status <- 400L + title <- "Each request body item must be a non-negative integer" + response$body <- list(title = title) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + response$body <- model$get_value_at_indices(name, indices) + response$format(json = reqres::format_json()) + return(FALSE) + } + + set_value <- function(request, response, keys, ...) { + request$parse(json = reqres::parse_json()) + model$set_value(last_segment(request$path), request$body) + response$status <- 204L + return(FALSE) + } + + set_value_at_indices <- function(request, response, keys, ...) { + request$parse(json = reqres::parse_json()) + name <- last_segment(request$path) + model$set_value_at_indices(name, request$body$indices, request$body$values) + response$status <- 204L + return(FALSE) + } + + get_grid_rank <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_rank(keys$grid) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_type <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- list(type = model$get_grid_type(keys$grid)) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_size <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_size(keys$grid) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_x <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_x(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_y <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_y(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_z <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_z(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_origin <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_origin(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_shape <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_shape(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_spacing <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_spacing(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_node_count <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_node_count(keys$grid) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_edge_count <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_edge_count(keys$grid) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_face_count <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_face_count(keys$grid) + response$format(json = reqres::format_json(auto_unbox = TRUE)) + return(FALSE) + } + + get_grid_edge_nodes <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_edge_nodes(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_face_edges <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_face_edges(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_face_nodes <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_face_nodes(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + get_grid_nodes_per_face <- function(request, response, keys, ...) { + response$status <- 200L + response$type <- "application/json" + response$body <- model$get_grid_nodes_per_face(keys$grid) + response$format(json = reqres::format_json()) + return(FALSE) + } + + route <- routr::Route$new() + # IRF + route$add_handler("post", "/initialize", bmi_initialize) + route$add_handler("post", "/update", update) + route$add_handler("post", "/update_until", update_until) + route$add_handler("delete", "/finalize", finalize) + + # Exchange items + route$add_handler("get", "/get_component_name", get_component_name) + route$add_handler("get", "/get_output_var_names", get_output_var_names) + route$add_handler("get", "/get_output_item_count", get_output_item_count) + route$add_handler("get", "/get_input_var_names", get_input_var_names) + route$add_handler("get", "/get_input_item_count", get_input_item_count) + + # Getters + route$add_handler("get", "/get_value/:name", get_value) + route$add_handler("post", "/get_value_at_indices/:name", get_value_at_indices) + + # Setters + route$add_handler("post", "/set_value/:name", set_value) + route$add_handler("post", "/set_value_at_indices/:name", set_value_at_indices) + + # Time information + route$add_handler("get", "/get_time_units", get_time_units) + route$add_handler("get", "/get_time_step", get_time_step) + route$add_handler("get", "/get_current_time", get_current_time) + route$add_handler("get", "/get_start_time", get_start_time) + route$add_handler("get", "/get_end_time", get_end_time) + + # Variable information + route$add_handler("get", "/get_var_grid/:name", get_var_grid) + route$add_handler("get", "/get_var_type/:name", get_var_type) + route$add_handler("get", "/get_var_itemsize/:name", get_var_itemsize) + route$add_handler("get", "/get_var_units/:name", get_var_units) + route$add_handler("get", "/get_var_nbytes/:name", get_var_nbytes) + route$add_handler("get", "/get_var_location/:name", get_var_location) + + # Grid information + route$add_handler("get", "/get_grid_rank/:grid", get_grid_rank) + route$add_handler("get", "/get_grid_type/:grid", get_grid_type) + route$add_handler("get", "/get_grid_size/:grid", get_grid_size) + + # NURC + route$add_handler("get", "/get_grid_x/:grid", get_grid_x) + route$add_handler("get", "/get_grid_y/:grid", get_grid_y) + route$add_handler("get", "/get_grid_z/:grid", get_grid_z) + + # Uniform rectilinear + route$add_handler("get", "/get_grid_origin/:grid", get_grid_origin) + route$add_handler("get", "/get_grid_shape/:grid", get_grid_shape) + route$add_handler("get", "/get_grid_spacing/:grid", get_grid_spacing) + + # # Unstructured + route$add_handler("get", "/get_grid_node_count/:grid", get_grid_node_count) + route$add_handler("get", "/get_grid_edge_count/:grid", get_grid_edge_count) + route$add_handler("get", "/get_grid_face_count/:grid", get_grid_face_count) + route$add_handler("get", "/get_grid_edge_nodes/:grid", get_grid_edge_nodes) + route$add_handler("get", "/get_grid_face_edges/:grid", get_grid_face_edges) + route$add_handler("get", "/get_grid_face_nodes/:grid", get_grid_face_nodes) + route$add_handler("get", "/get_grid_nodes_per_face/:grid", get_grid_nodes_per_face) # nolint: line_length_linter. + + # TODO Needed? + fallback <- function(request, response, keys, ...) { + response$status <- 404L + response$type <- "text/plain" + response$body <- "Not found" + return(FALSE) + } + route$add_handler("get", "/*", fallback) + + return(route) +} diff --git a/R/remotebmi/R/server.R b/R/remotebmi/R/server.R new file mode 100644 index 0000000..a45540c --- /dev/null +++ b/R/remotebmi/R/server.R @@ -0,0 +1,27 @@ +library(fiery) +library(routr) +library(reqres) + +#' Serve the BMI model +#' +#' This function serves the model on a specified port and host. +#' +#' @param model The model instance to be served. Must implement the subclass of [AbstractBmi](https://github.com/eWaterCycle/bmi-r/blob/master/R/abstract-bmi.R) +#' @param port The port to serve the model on. Default is 50051 or if BMI_PORT environment variable is set, it will be used. +#' @param host The host to serve the model on. Default is "127.0.0.1". +#' @param ignite Whether to ignite the server immediately and block. Default is TRUE. +#' @return The server application. +#' @export +serve <- function(model, port = 50051, host = "127.0.0.1", ignite = TRUE) { + route <- create_route(model) + router <- routr::RouteStack$new() + router$add_route(route, "bmi") + + port <- as.integer(Sys.getenv("BMI_PORT", port)) + app <- fiery::Fire$new(host = host, port = port) + app$attach(router) + if (ignite) { + app$ignite() + } + return(app) +} diff --git a/R/remotebmi/README.md b/R/remotebmi/README.md new file mode 100644 index 0000000..ae46cb8 --- /dev/null +++ b/R/remotebmi/README.md @@ -0,0 +1,107 @@ + +# remotebmi + + + + +The goal of remotebmi is to allow a model with BMI interface written in R to be called from a Python client via a http json webservice. + +## Installation + +You can install the development version of remotebmi from [GitHub](https://github.com/) with: + +``` r +# install.packages("pak") +pak::pak("github::eWaterCycle/remotebmi/R/remotebmi") +``` + +## Example + +This is a basic example which shows you how to solve a common problem: + +``` r +library(remotebmi) +## basic example code + +pak::pak("github::ClaudiaBrauer/WALRUS") +pak::pak("github::eWaterCycle/bmi-r") +pak::pak('configr') +source('https://github.com/eWaterCycle/grpc4bmi-examples/raw/master/walrus/walrus-bmi.r') +model <- WalrusBmi$new() + +remotebmi::serve(model) +Fire started at 127.0.0.1:50051 +``` + +With Python client test the model + +```python +import os +from remotebmi.client.client import RemoteBmiClient +from remotebmi.reserve import reserve_values, reserve_grid_padding, reserve_grid_shape +import numpy as np + +client = RemoteBmiClient('http://localhost:50051') +!wget https://github.com/eWaterCycle/grpc4bmi-examples/raw/refs/heads/master/walrus/walrus.yml +!wget https://github.com/ClaudiaBrauer/WALRUS/raw/refs/heads/master/demo/data/PEQ_Hupsel.dat +# Make data path in walrus.yml absolute +client.initialize( os.getcwd() + '/walrus.yml') +client.update() +# model information functions +client.get_component_name() +'WALRUS' +client.get_output_var_names() +['ETact', 'Q', 'fGS', 'fQS', 'dV', 'dVeq', 'dG', 'hQ', 'hS', 'w'] +client.get_output_item_count() +10 +client.get_input_var_names() +# kaput, R server returns '' instead of '[]', while reqres::format_json()(list()) does return '[]' +client.get_input_item_count() +0 +# Time functions +client.get_current_time() +367417 +client.get_time_units() +'hours since 1970-01-01 00:00:00.0 00:00' +client.get_time_step() +1 +client.get_end_time() +368904 +client.get_start_time() +367416 +# Variable information functions +client.get_var_grid('Q') +0 +client.get_var_type('Q') +numpy.float64 +client.get_var_units('Q') +'mm/h' +client.get_var_itemsize('Q') +8 +client.get_var_nbytes('Q') +8 +client.get_var_location('Q') +'node' +# Variable getter and setter functions +client.get_value('Q', dest=np.array([.1])) +array([0.0044]) +client.get_value_at_indices('Q', dest=np.array([0.0]), inds=np.array([0])) +array([0.0044]) # walrus ignores inds and just always returns lumped value +# setter not implemented in walrus +# Model grid functions +client.get_grid_rank(0) +2 +client.get_grid_type(0) +'uniform_rectilinear' +client.getgrid_size(0) +1 +client.get_grid_shape(0, reserve_grid_shape(client, 0)) +array([1, 1]) +client.get_grid_origin(0, reserve_grid_padding(client, 0)) +array([ 6.6544, 52.0613]) +client.get_grid_spacing(0, reserve_grid_padding(client, 0)) +array([0, 0]) +# Other grid function not needed for walrus +# And finally +client.finalize() +``` diff --git a/R/remotebmi/man/create_route.Rd b/R/remotebmi/man/create_route.Rd new file mode 100644 index 0000000..d6c23c1 --- /dev/null +++ b/R/remotebmi/man/create_route.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/route.R +\name{create_route} +\alias{create_route} +\title{Create a Route for the Given Model} +\usage{ +create_route(model) +} +\arguments{ +\item{model}{The model instance to be used in route handlers. +Must implement the subclass of +\href{https://github.com/eWaterCycle/bmi-r/blob/master/R/abstract-bmi.R}{AbstractBmi} # nolint: line_length_linter.} +} +\value{ +A route object that can be used to interact with the model. +} +\description{ +This function generates a route for the specified model. The route is used to +facilitate communication and interaction with the model. +} +\examples{ +\dontrun{ +model <- SomeModel$new() +route <- create_route(model) +} + +} diff --git a/R/remotebmi/man/serve.Rd b/R/remotebmi/man/serve.Rd new file mode 100644 index 0000000..af1f830 --- /dev/null +++ b/R/remotebmi/man/serve.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/server.R +\name{serve} +\alias{serve} +\title{Serve the BMI model} +\usage{ +serve(model, port = 50051, host = "127.0.0.1", ignite = TRUE) +} +\arguments{ +\item{model}{The model instance to be served. Must implement the subclass of \href{https://github.com/eWaterCycle/bmi-r/blob/master/R/abstract-bmi.R}{AbstractBmi}} + +\item{port}{The port to serve the model on. Default is 50051 or if BMI_PORT environment variable is set, it will be used.} + +\item{host}{The host to serve the model on. Default is "127.0.0.1".} + +\item{ignite}{Whether to ignite the server immediately and block. Default is TRUE.} +} +\value{ +The server application. +} +\description{ +This function serves the model on a specified port and host. +} diff --git a/R/remotebmi/tests/testthat.R b/R/remotebmi/tests/testthat.R new file mode 100644 index 0000000..4cdde84 --- /dev/null +++ b/R/remotebmi/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(remotebmi) + +test_check("remotebmi") diff --git a/R/remotebmi/tests/testthat/test-route.R b/R/remotebmi/tests/testthat/test-route.R new file mode 100644 index 0000000..25e8729 --- /dev/null +++ b/R/remotebmi/tests/testthat/test-route.R @@ -0,0 +1,606 @@ +# Poor mans mock +method_called_with <- list() +# Mock model object +# TODO add more functions see +# https://github.com/eWaterCycle/grpc4bmi/blob/main/test/fake_models.py +# TODO use bmi-r::AbstractModel and R6Class to make proper subclass +mock_model <- list( + bmi_initialize = function(config_file) { + method_called_with[["bmi_initialize"]] <<- as.character(config_file) + }, + update = function() { + method_called_with[["update"]] <<- TRUE + }, + update_until = function(time) { + method_called_with[["update_until"]] <<- as.numeric(time) + }, + bmi_finalize = function() { + method_called_with[["bmi_finalize"]] <<- TRUE + }, + get_component_name = function() "Mock Component", + get_output_var_names = function() c("var1", "var2"), + get_output_item_count = function() 2, + get_input_var_names = function() c(), + get_input_item_count = function() 0, + get_time_units = function() "h", + get_time_step = function() 1, + get_current_time = function() 12, + get_start_time = function() 0, + get_end_time = function() 168, + get_var_grid = function(name) { + method_called_with[["get_var_grid"]] <<- as.character(name) + return(1) + }, + get_var_type = function(name) { + method_called_with[["get_var_type"]] <<- as.character(name) + return("float64") + }, + get_var_nbytes = function(name) { + method_called_with[["get_var_nbytes"]] <<- as.character(name) + return(8) + }, + get_var_itemsize = function(name) { + method_called_with[["get_var_itemsize"]] <<- as.character(name) + return(8) + }, + get_var_location = function(name) { + method_called_with[["get_var_location"]] <<- as.character(name) + return("node") + }, + get_var_units = function(name) { + method_called_with[["get_var_units"]] <<- as.character(name) + return("unit1") + }, + get_value = function(name) { + method_called_with[["get_value"]] <<- list(name = name) + return(4.2) + }, + get_value_at_indices = function(name, indices) { + args <- list(name = name, indices = indices) + method_called_with[["get_value_at_indices"]] <<- args + return(4.2) + }, + set_value = function(name, value) { + method_called_with[["set_value"]] <<- list(name = name, value = value) + }, + set_value_at_indices = function(name, indices, values) { + args <- list(name = name, indices = indices, values = values) + method_called_with[["set_value_at_indices"]] <<- args + }, + get_grid_rank = function(grid_id) { + method_called_with[["get_grid_rank"]] <<- as.character(grid_id) + return(24) + }, + get_grid_type = function(grid_id) { + method_called_with[["get_grid_type"]] <<- as.character(grid_id) + return("uniform_rectilinear") + }, + get_grid_size = function(grid_id) { + method_called_with[["get_grid_size"]] <<- as.character(grid_id) + return(24) + }, + get_grid_x = function(grid_id) { + method_called_with[["get_grid_x"]] <<- as.character(grid_id) + return(c(0.1, 0.2, 0.3, 0.4)) + }, + get_grid_y = function(grid_id) { + method_called_with[["get_grid_y"]] <<- as.character(grid_id) + return(c(1.1, 1.2, 1.3)) + }, + get_grid_z = function(grid_id) { + method_called_with[["get_grid_z"]] <<- as.character(grid_id) + return(c(2.1, 2.2)) + }, + get_grid_shape = function(grid_id) { + method_called_with[["get_grid_shape"]] <<- as.character(grid_id) + return(c(2, 3, 4)) + }, + get_grid_origin = function(grid_id) { + method_called_with[["get_grid_origin"]] <<- as.character(grid_id) + return(c(0.1, 1.1, 2.1)) + }, + get_grid_spacing = function(grid_id) { + method_called_with[["get_grid_spacing"]] <<- as.character(grid_id) + return(c(0.1, 0.2, 0.3)) + }, + get_grid_node_count = function(grid_id) { + method_called_with[["get_grid_node_count"]] <<- as.character(grid_id) + return(6) + }, + get_grid_edge_count = function(grid_id) { + method_called_with[["get_grid_edge_count"]] <<- as.character(grid_id) + return(8) + }, + get_grid_face_count = function(grid_id) { + method_called_with[["get_grid_face_count"]] <<- as.character(grid_id) + return(3) + }, + get_grid_edge_nodes = function(grid_id) { + method_called_with[["get_grid_edge_nodes"]] <<- as.character(grid_id) + return(c(0, 1, 1, 2, 2, 3, 3, 0, 1, 4, 4, 5, 5, 2, 5, 3)) + }, + get_grid_face_edges = function(grid_id) { + method_called_with[["get_grid_face_edges"]] <<- as.character(grid_id) + return(c(0, 1, 2, 3, 4, 5, 6, 1, 6, 7, 2)) + }, + get_grid_face_nodes = function(grid_id) { + method_called_with[["get_grid_face_nodes"]] <<- as.character(grid_id) + return(c(0, 1, 2, 3, 1, 4, 5, 2, 2, 5, 3)) + }, + get_grid_nodes_per_face = function(grid_id) { + method_called_with[["get_grid_nodes_per_face"]] <<- as.character(grid_id) + return(c(4, 4, 3)) + } +) + +route <- create_route(mock_model) +formatter <- reqres::format_json(auto_unbox = TRUE) + +test_that("/get_component_name", { + fake_rook <- fiery::fake_request("/get_component_name") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(name = "Mock Component"))) +}) + +test_that("/get_output_var_names", { + fake_rook <- fiery::fake_request("/get_output_var_names") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c("var1", "var2"))) +}) + +test_that("/initialize", { + fake_rook <- fiery::fake_request("/initialize", + content = '{"config_file": "some_config"}', + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 201) + expect_equal(method_called_with[["bmi_initialize"]], "some_config") +}) + +test_that("/update", { + fake_rook <- fiery::fake_request("/update", + method = "post" + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 204) + expect_equal(method_called_with[["update"]], TRUE) +}) + +test_that("/update_until", { + fake_rook <- fiery::fake_request("/update_until", + content = "113", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 204) + expect_equal(method_called_with[["update_until"]], 113) +}) + +test_that("/finalize", { + fake_rook <- fiery::fake_request("/finalize", + method = "delete" + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 204) + expect_equal(method_called_with[["bmi_finalize"]], TRUE) +}) + +test_that("/get_input_var_names", { + fake_rook <- fiery::fake_request("/get_input_var_names") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, "[]") +}) + +test_that("/get_input_item_count", { + fake_rook <- fiery::fake_request("/get_input_item_count") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(0)) +}) + +test_that("/get_output_item_count", { + fake_rook <- fiery::fake_request("/get_output_item_count") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(2)) +}) + +test_that("/get_time_units", { + fake_rook <- fiery::fake_request("/get_time_units") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(units = "h"))) +}) + +test_that("/get_time_step", { + fake_rook <- fiery::fake_request("/get_time_step") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(1)) +}) + +test_that("/get_current_time", { + fake_rook <- fiery::fake_request("/get_current_time") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(12)) +}) + +test_that("/get_start_time", { + fake_rook <- fiery::fake_request("/get_start_time") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(0)) +}) + +test_that("/get_end_time", { + fake_rook <- fiery::fake_request("/get_end_time") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(168)) +}) + +test_that("/get_var_units", { + fake_rook <- fiery::fake_request("/get_var_units/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(units = "unit1"))) + expect_equal(method_called_with[["get_var_units"]], "Q") +}) + +test_that("/get_var_grid", { + fake_rook <- fiery::fake_request("/get_var_grid/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(1)) + expect_equal(method_called_with[["get_var_grid"]], "Q") +}) + +test_that("/get_var_type", { + fake_rook <- fiery::fake_request("/get_var_type/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(type = "double"))) + expect_equal(method_called_with[["get_var_type"]], "Q") +}) + +test_that("/get_var_nbytes", { + fake_rook <- fiery::fake_request("/get_var_nbytes/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(8)) + expect_equal(method_called_with[["get_var_nbytes"]], "Q") +}) + +test_that("/get_var_itemsize", { + fake_rook <- fiery::fake_request("/get_var_itemsize/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(8)) + expect_equal(method_called_with[["get_var_itemsize"]], "Q") +}) + +test_that("/get_var_location", { + fake_rook <- fiery::fake_request("/get_var_location/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(location = "node"))) + expect_equal(method_called_with[["get_var_location"]], "Q") +}) + +test_that("/get_value", { + fake_rook <- fiery::fake_request("/get_value/Q") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(4.2))) + expect_equal(method_called_with[["get_value"]], list(name = "Q")) +}) + +test_that("/get_value_at_indices", { + fake_rook <- fiery::fake_request("/get_value_at_indices/Q", + content = "[1, 2, 3]", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(4.2))) + expected <- list(name = "Q", indices = c(1, 2, 3)) + expect_equal(method_called_with[["get_value_at_indices"]], expected) +}) + +test_that("/get_value_at_indices, given string", { + fake_rook <- fiery::fake_request("/get_value_at_indices/Q", + content = "foobar", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 400) + expected <- list( + title = "Request body must be an array" + ) + expect_equal(res$body, formatter(expected)) +}) + +test_that("/get_value_at_indices, given empty list", { + fake_rook <- fiery::fake_request("/get_value_at_indices/Q", + content = "[]", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 400) + expected <- list( + title = "Request body must be a non-empty list" + ) + expect_equal(res$body, formatter(expected)) +}) + +test_that("/get_value_at_indices, given negative index", { + fake_rook <- fiery::fake_request("/get_value_at_indices/Q", + content = "[-1]", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 400) + expected <- list( + title = "Each request body item must be a non-negative integer" + ) + expect_equal(res$body, formatter(expected)) +}) + +test_that("/set_value", { + fake_rook <- fiery::fake_request("/set_value/Q", + content = "[4.2]", + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 204) + expected <- list(name = "Q", value = c(4.2)) + expect_equal(method_called_with[["set_value"]], expected) +}) + +test_that("set_value_at_indices", { + fake_rook <- fiery::fake_request("/set_value_at_indices/Q", + content = '{"indices": [1, 2, 3], "values": [1.1, 2.2, 3.3]}', + method = "post", + headers = list("Content_Type" = "application/json") + ) + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 204) + expected <- list(name = "Q", indices = c(1, 2, 3), values = c(1.1, 2.2, 3.3)) + expect_equal(method_called_with[["set_value_at_indices"]], expected) +}) + +test_that("/get_grid_rank", { + fake_rook <- fiery::fake_request("/get_grid_rank/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(24)) + expect_equal(method_called_with[["get_grid_rank"]], "1") +}) + +test_that("/get_grid_type", { + fake_rook <- fiery::fake_request("/get_grid_type/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(list(type = "uniform_rectilinear"))) + expect_equal(method_called_with[["get_grid_type"]], "1") +}) + +test_that("/get_grid_size", { + fake_rook <- fiery::fake_request("/get_grid_size/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(24)) + expect_equal(method_called_with[["get_grid_size"]], "1") +}) + +test_that("/get_grid_x", { + fake_rook <- fiery::fake_request("/get_grid_x/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(0.1, 0.2, 0.3, 0.4))) + expect_equal(method_called_with[["get_grid_x"]], "1") +}) + +test_that("/get_grid_y", { + fake_rook <- fiery::fake_request("/get_grid_y/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(1.1, 1.2, 1.3))) + expect_equal(method_called_with[["get_grid_y"]], "1") +}) + +test_that("/get_grid_z", { + fake_rook <- fiery::fake_request("/get_grid_z/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(2.1, 2.2))) + expect_equal(method_called_with[["get_grid_z"]], "1") +}) + +test_that("/get_grid_shape", { + fake_rook <- fiery::fake_request("/get_grid_shape/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(2, 3, 4))) + expect_equal(method_called_with[["get_grid_shape"]], "1") +}) + +test_that("/get_grid_origin", { + fake_rook <- fiery::fake_request("/get_grid_origin/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(0.1, 1.1, 2.1))) + expect_equal(method_called_with[["get_grid_origin"]], "1") +}) + +test_that("/get_grid_spacing", { + fake_rook <- fiery::fake_request("/get_grid_spacing/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(0.1, 0.2, 0.3))) + expect_equal(method_called_with[["get_grid_spacing"]], "1") +}) + +test_that("/get_grid_node_count", { + fake_rook <- fiery::fake_request("/get_grid_node_count/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(6)) + expect_equal(method_called_with[["get_grid_node_count"]], "1") +}) + +test_that("/get_grid_edge_count", { + fake_rook <- fiery::fake_request("/get_grid_edge_count/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(8)) + expect_equal(method_called_with[["get_grid_edge_count"]], "1") +}) + +test_that("/get_grid_face_count", { + fake_rook <- fiery::fake_request("/get_grid_face_count/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(3)) + expect_equal(method_called_with[["get_grid_face_count"]], "1") +}) + +test_that("/get_grid_edge_nodes", { + fake_rook <- fiery::fake_request("/get_grid_edge_nodes/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + en <- c(0, 1, 1, 2, 2, 3, 3, 0, 1, 4, 4, 5, 5, 2, 5, 3) + expect_equal(res$body, formatter(en)) + expect_equal(method_called_with[["get_grid_edge_nodes"]], "1") +}) + +test_that("/get_grid_face_edges", { + fake_rook <- fiery::fake_request("/get_grid_face_edges/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(0, 1, 2, 3, 4, 5, 6, 1, 6, 7, 2))) + expect_equal(method_called_with[["get_grid_face_edges"]], "1") +}) + +test_that("/get_grid_face_nodes", { + fake_rook <- fiery::fake_request("/get_grid_face_nodes/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(0, 1, 2, 3, 1, 4, 5, 2, 2, 5, 3))) + expect_equal(method_called_with[["get_grid_face_nodes"]], "1") +}) + +test_that("/get_grid_nodes_per_face", { + fake_rook <- fiery::fake_request("/get_grid_nodes_per_face/1") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 200) + expect_equal(res$body, formatter(c(4, 4, 3))) + expect_equal(method_called_with[["get_grid_nodes_per_face"]], "1") +}) + +test_that("fallback route", { + fake_rook <- fiery::fake_request("/random_url_is_not_found") + req <- reqres::Request$new(fake_rook) + res <- req$respond() + route$dispatch(req) + expect_equal(res$status, 404) + expect_equal(res$body, "Not found") +}) \ No newline at end of file diff --git a/README.md b/README.md index 9cc4c9f..5452660 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Remote BMI +[![Codecov test coverage](https://codecov.io/gh/eWaterCycle/remotebmi/graph/badge.svg)](https://app.codecov.io/gh/eWaterCycle/remotebmi) The [Basic Model Interface (BMI)](https://bmi.readthedocs.io/en/stable/) is a standard interface for models. The interface is available in different languages and a [language agnosting version in SIDL](https://github.com/csdms/bmi/blob/stable/bmi.sidl). @@ -108,8 +109,7 @@ library(remotebmi) library(MyModel) port = as.integer(Sys.getenv("BMI_PORT", 50051)) -server <- RemoteBmiServer(MyModel$ModelBmi, port=port, host="localhost") -server$run() +serve(MyModel::ModelBmi$new(), port=port, host="localhost") ``` ### Other languages diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..dc5f2b0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,20 @@ +comment: + layout: "header, diff, flags, components" # show component info in the PR comment + +component_management: + default_rules: # default rules that will be inherited by all components + statuses: + - type: project # in this case every component that doens't have a status defined will have a project type one + target: auto + branches: + - "!main" + individual_components: + - component_id: Python + paths: + - python/** + - component_id: Julia + paths: + - RemoteBMI.jl/** + - component_id: R + paths: + - R/remotebmi/** diff --git a/openapi.yaml b/openapi.yaml index 455c63a..7ffa01a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1138,11 +1138,13 @@ components: format: int64 description: Values of variable are indexed 0-based. minimum: 0 + minItems: 1 Doubles: type: array items: type: number format: double + minItems: 1 GetGridRankResponse: type: integer format: int32 @@ -1186,10 +1188,12 @@ components: type: array items: type: number + minItems: 1 GetValueResponse: type: array items: type: number + minItems: 1 GetVarGridResponse: type: integer format: int32 @@ -1250,10 +1254,12 @@ components: description: Values of variable are indexed 0-based. format: int64 minimum: 0 + minItems: 1 values: type: array items: type: number + minItems: 1 required: - indices - values @@ -1262,6 +1268,7 @@ components: type: array items: type: number + minItems: 1 Grid: type: integer format: int32 diff --git a/python/pyproject.toml b/python/pyproject.toml index 1e02062..04a1abc 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "bmipy", "numpy", # client - "httpx", + "httpx>=0.27.2", # server "connexion", "uvicorn", @@ -18,6 +18,12 @@ dependencies = [ "docker", ] +[project.optional-dependencies] +dev = [ + "ruff", + "pytest" +] + [project.scripts] run-bmi-server='remotebmi.server:main' diff --git a/python/remotebmi/client/apptainer.py b/python/remotebmi/client/apptainer.py index 813e49a..1ef9e08 100644 --- a/python/remotebmi/client/apptainer.py +++ b/python/remotebmi/client/apptainer.py @@ -37,9 +37,7 @@ def __init__( self.work_dir = abspath(work_dir) if self.work_dir in {abspath(d) for d in input_dirs}: msg = "Found work_dir equal to one of the input directories. Please drop that input dir." - raise ValueError( - msg - ) + raise ValueError(msg) if not os.path.isdir(self.work_dir): # noqa: PTH112 raise NotADirectoryError(self.work_dir) args += ["--bind", f"{self.work_dir}:{self.work_dir}:rw"] @@ -48,7 +46,7 @@ def __init__( args.append(image) logging.info(f"Running {image} apptainer container on port {port}") if capture_logs: - self.logfile = SpooledTemporaryFile( + self.logfile = SpooledTemporaryFile( # noqa: SIM115 - file is closed in __del__ max_size=2**16, # keep until 65Kb in memory if bigger write to disk prefix="grpc4bmi-apptainer-log", mode="w+t", @@ -58,12 +56,17 @@ def __init__( else: stdout = subprocess.DEVNULL self.container = subprocess.Popen( # noqa: S603 - args, preexec_fn=os.setsid, stderr=subprocess.STDOUT, stdout=stdout # noqa: PLW1509 if absent leaves zombie processes behind + args, + preexec_fn=os.setsid, # noqa: PLW1509 + stderr=subprocess.STDOUT, + stdout=stdout, ) time.sleep(delay) returncode = self.container.poll() if returncode is not None: - msg = f"apptainer container {image} prematurely exited with code {returncode}" + msg = ( + f"apptainer container {image} prematurely exited with code {returncode}" + ) raise DeadContainerError( msg, returncode, diff --git a/python/remotebmi/client/client.py b/python/remotebmi/client/client.py index 2333620..3355bd2 100644 --- a/python/remotebmi/client/client.py +++ b/python/remotebmi/client/client.py @@ -1,19 +1,23 @@ import numpy as np from bmipy import Bmi -from httpx import Client +from httpx import Client, Limits from numpy import ndarray class RemoteBmiClient(Bmi): - def __init__(self, base_url, timeout=60 * 60 * 24): + def __init__(self, base_url, timeout=60 * 60 * 24, max_keepalive_connections=0): """RemoteBmiClient constructor Args: base_url: Where the remote BMI server is running. - timeout: How long a response can take. + timeout: How long a response can take. Defaults to 1 day. Set to None to disable timeout. + max_keepalive_connections: How many connections to keep alive. """ - self.client = Client(base_url=base_url, timeout=timeout) + # In some Python environments the reusing connection causes `illegal status line: bytesarray(b'14')` error + # So we need to disable keepalive connections to be more reliable, but less efficient + limits = Limits(max_keepalive_connections=max_keepalive_connections) + self.client = Client(base_url=base_url, timeout=timeout, limits=limits) def __del__(self): self.client.close() diff --git a/python/remotebmi/client/docker.py b/python/remotebmi/client/docker.py index 4b49696..7a7e761 100644 --- a/python/remotebmi/client/docker.py +++ b/python/remotebmi/client/docker.py @@ -40,9 +40,7 @@ def __init__( self.work_dir = abspath(work_dir) if self.work_dir in volumes: msg = "Found work_dir equal to one of the input directories. Please drop that input dir." - raise ValueError( - msg - ) + raise ValueError(msg) if not os.path.isdir(self.work_dir): # noqa: PTH112 raise NotADirectoryError(self.work_dir) volumes[self.work_dir] = {"bind": self.work_dir, "mode": "rw"} diff --git a/python/remotebmi/server/api.py b/python/remotebmi/server/api.py index aa61e06..3ebe349 100644 --- a/python/remotebmi/server/api.py +++ b/python/remotebmi/server/api.py @@ -62,7 +62,7 @@ def get_var_grid(name: str): def get_var_type(name: str): - return {"type": model().get_var_type(name) } + return {"type": model().get_var_type(name)} def get_var_units(name: str): @@ -85,8 +85,10 @@ def get_value(name: str): items = reserve_values(model(), name) return model().get_value(name, items) + # TODO correct typings + def get_value_at_indices(name: str, indices: list): items = reserve_values_at_indices(model(), name, indices) return model().get_value_at_indices(name, indices, items) diff --git a/python/remotebmi/server/build.py b/python/remotebmi/server/build.py index 7a3170c..2eb4384 100644 --- a/python/remotebmi/server/build.py +++ b/python/remotebmi/server/build.py @@ -29,9 +29,7 @@ def from_env() -> Bmi: "Missing module name: module could not be derived from environment " f"variable {ENV_BMI_MODULE}" ) - raise ValueError( - msg - ) + raise ValueError(msg) class_name = os.environ.get(ENV_BMI_CLASS, "") if not class_name: @@ -39,9 +37,7 @@ def from_env() -> Bmi: "Missing bmi implementation: class could not be derived from environment" f"variable {ENV_BMI_CLASS}" ) - raise ValueError( - msg - ) + raise ValueError(msg) return build(module_name, class_name, path) diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/fake_models.py b/python/tests/fake_models.py new file mode 100644 index 0000000..86cd211 --- /dev/null +++ b/python/tests/fake_models.py @@ -0,0 +1,433 @@ +"""Fake models for testing purposes. + +Copied from https://github.com/eWaterCycle/grpc4bmi/blob/main/test/fake_models.py +""" + +import numpy as np +from bmipy import Bmi + + +class SomeError(Exception): + pass + + +class FailingModel(Bmi): + def __init__(self, exc): + self.exc = exc + + def initialize(self, filename): + raise self.exc + + def update(self): + raise self.exc + + def update_until(self, time: float) -> None: + raise self.exc + + def finalize(self): + raise self.exc + + def get_component_name(self): + raise self.exc + + def get_input_item_count(self) -> int: + raise self.exc + + def get_output_item_count(self) -> int: + raise self.exc + + def get_input_var_names(self): + raise self.exc + + def get_output_var_names(self): + raise self.exc + + def get_start_time(self): + raise self.exc + + def get_current_time(self): + raise self.exc + + def get_end_time(self): + raise self.exc + + def get_time_step(self): + raise self.exc + + def get_time_units(self): + raise self.exc + + def get_var_type(self, name): + raise self.exc + + def get_var_units(self, name): + raise self.exc + + def get_var_itemsize(self, name): + raise self.exc + + def get_var_nbytes(self, name): + raise self.exc + + def get_var_grid(self, name): + raise self.exc + + def get_value(self, name, dest): + raise self.exc + + def get_value_ptr(self, name): + raise self.exc + + def get_value_at_indices(self, name, dest, inds): + raise self.exc + + def set_value(self, name, src): + raise self.exc + + def set_value_at_indices(self, name, inds, src): + raise self.exc + + def get_grid_shape(self, grid, shape): + raise self.exc + + def get_grid_x(self, grid, x): + raise self.exc + + def get_grid_y(self, grid, y): + raise self.exc + + def get_grid_z(self, grid, z): + raise self.exc + + def get_grid_spacing(self, grid, spacing): + raise self.exc + + def get_grid_origin(self, grid, origin): + raise self.exc + + def get_grid_rank(self, grid): + raise self.exc + + def get_grid_size(self, grid): + raise self.exc + + def get_grid_type(self, grid): + raise self.exc + + def get_var_location(self, name: str) -> str: + raise self.exc + + def get_grid_node_count(self, grid: int) -> int: + raise self.exc + + def get_grid_edge_count(self, grid: int) -> int: + raise self.exc + + def get_grid_face_count(self, grid: int) -> int: + raise self.exc + + def get_grid_edge_nodes(self, grid: int, edge_nodes: np.ndarray) -> np.ndarray: + raise self.exc + + def get_grid_face_nodes(self, grid: int, face_nodes: np.ndarray) -> np.ndarray: + raise self.exc + + def get_grid_nodes_per_face( + self, grid: int, nodes_per_face: np.ndarray + ) -> np.ndarray: + raise self.exc + + def get_grid_face_edges(self, grid: int, face_edges: np.ndarray) -> np.ndarray: + raise self.exc + + +class GridModel(FailingModel): + def __init__(self): + super().__init__(SomeError("not used")) + + def initialize(self, filename): + pass + + def get_output_var_names(self) -> tuple[str]: + return ("plate_surface__temperature",) + + def get_var_grid(self, name): + return 0 + + +class UniRectGridModel(GridModel): + def get_grid_type(self, grid): + return "uniform_rectilinear" + + def get_grid_rank(self, grid): + return 3 + + def get_grid_size(self, grid): + return 24 + + def get_grid_shape(self, grid: int, shape: np.ndarray) -> np.ndarray: + np.copyto(src=[2, 3, 4], dst=shape) + return shape + + def get_grid_origin(self, grid, dest): + np.copyto(src=[0.1, 1.1, 2.1], dst=dest) + return dest + + def get_grid_spacing(self, grid, dest): + np.copyto(src=[0.1, 0.2, 0.3], dst=dest) + return dest + + +class Rect3DGridModel(GridModel): + def get_grid_type(self, grid): + return "rectilinear" + + def get_grid_size(self, grid): + return 24 + + def get_grid_rank(self, grid: int) -> int: + return 3 + + def get_grid_shape(self, grid: int, shape: np.ndarray) -> np.ndarray: + np.copyto(src=[2, 3, 4], dst=shape) + return shape + + def get_grid_x(self, grid: int, x: np.ndarray) -> np.ndarray: + np.copyto(src=[0.1, 0.2, 0.3, 0.4], dst=x) + return x + + def get_grid_y(self, grid: int, y: np.ndarray) -> np.ndarray: + np.copyto(src=[1.1, 1.2, 1.3], dst=y) + return y + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + np.copyto(src=[2.1, 2.2], dst=z) + return z + + +class Rect2DGridModel(Rect3DGridModel): + def get_grid_size(self, grid): + return 12 # 4*3 + + def get_grid_rank(self, grid: int) -> int: + return 2 + + def get_grid_shape(self, grid: int, shape: np.ndarray) -> np.ndarray: + np.copyto(src=[3, 4], dst=shape) + return shape + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + msg = "Do not know what z is" + raise NotImplementedError(msg) + + +class Structured3DQuadrilateralsGridModel(GridModel): + # Grid shape: + # 0 + # / \ + # / \ + # 3 1 + # \ / + # \ / + # 2 + # + def get_grid_type(self, grid): + return "structured_quadrilateral" + + def get_grid_rank(self, grid: int) -> int: + return 3 + + def get_grid_size(self, grid): + return 4 + + def get_grid_shape(self, grid, shape): + np.copyto(src=[1, 2, 2], dst=shape) + return shape + + def get_grid_x(self, grid, x): + np.copyto(src=[1.1, 0.1, 1.1, 2.1], dst=x) + return x + + def get_grid_y(self, grid: int, y: np.ndarray) -> np.ndarray: + np.copyto(src=[2.2, 1.2, 0.2, 2.2], dst=y) + return y + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + np.copyto(src=[1.1, 2.2, 3.3, 4.4], dst=z) + return z + + +class Structured2DQuadrilateralsGridModel(GridModel): + # Grid shape: + # 0 + # / \ + # / \ + # 3 1 + # \ / + # \ / + # 2 + # + def get_grid_type(self, grid): + return "structured_quadrilateral" + + def get_grid_rank(self, grid: int) -> int: + return 2 + + def get_grid_size(self, grid): + return 4 + + def get_grid_shape(self, grid, shape): + np.copyto(src=[2, 2], dst=shape) + return shape + + def get_grid_x(self, grid, x): + np.copyto(src=[1.1, 0.1, 1.1, 2.1], dst=x) + return x + + def get_grid_y(self, grid: int, y: np.ndarray) -> np.ndarray: + np.copyto(src=[2.2, 1.2, 0.2, 2.2], dst=y) + return y + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + msg = "Do not know what z is" + raise NotImplementedError(msg) + + +class UnstructuredGridBmiModel(GridModel): + # Uses grid example at https://bmi.readthedocs.io/en/latest/model_grids.html#unstructured-grids + # Grid shape: + # 3-----\ + # / \ \ + # 0 \ \---5 + # \ 2--/ / + # \ / / + # \ / / + # 1---\ / + # 4 + def get_grid_type(self, grid): + return "unstructured" + + def get_grid_shape(self, grid, dest): + msg = "Do not know what shape is" + raise NotImplementedError(msg) + + def get_grid_size(self, grid): + return 6 + + def get_grid_rank(self, grid: int) -> int: + return 2 + + def get_grid_node_count(self, grid: int) -> int: + return 6 + + def get_grid_edge_count(self, grid: int) -> int: + return 8 + + def get_grid_face_count(self, grid: int) -> int: + return 3 + + def get_grid_edge_nodes(self, grid: int, edge_nodes: np.ndarray) -> np.ndarray: + np.copyto(src=(0, 1, 1, 2, 2, 3, 3, 0, 1, 4, 4, 5, 5, 2, 5, 3), dst=edge_nodes) + return edge_nodes + + def get_grid_face_nodes(self, grid: int, face_nodes: np.ndarray) -> np.ndarray: + np.copyto(src=(0, 1, 2, 3, 1, 4, 5, 2, 2, 5, 3), dst=face_nodes) + return face_nodes + + def get_grid_face_edges(self, grid: int, face_edges: np.ndarray) -> np.ndarray: + np.copyto(src=(0, 1, 2, 3, 4, 5, 6, 1, 6, 7, 2), dst=face_edges) + return face_edges + + def get_grid_nodes_per_face( + self, grid: int, nodes_per_face: np.ndarray + ) -> np.ndarray: + np.copyto(src=(4, 4, 3), dst=nodes_per_face) + return nodes_per_face + + def get_grid_x(self, grid: int, x: np.ndarray) -> np.ndarray: + np.copyto(src=[0.0, 1.0, 2.0, 1.0, 3.0, 4.0], dst=x) + return x + + def get_grid_y(self, grid: int, y: np.ndarray) -> np.ndarray: + np.copyto(src=[3.0, 1.0, 2.0, 4.0, 0.0, 3.0], dst=y) + return y + + def get_grid_z(self, grid: int, z: np.ndarray) -> np.ndarray: + msg = "Do not know what z is" + raise NotImplementedError(msg) + + +class DTypeModel(GridModel): + def __init__(self): + super().__init__() + self.dtype = np.dtype("float32") + self.value = np.array((1.1, 2.2, 3.3), dtype=self.dtype) + + def get_var_type(self, name): + return str(self.dtype) + + def get_var_itemsize(self, name): + return self.dtype.itemsize + + def get_var_nbytes(self, name): + return self.dtype.itemsize * self.value.size + + def get_value(self, name, dest): + np.copyto(src=self.value, dst=dest) + return dest + + def get_value_at_indices(self, name, dest, inds): + np.copyto(src=self.value[inds], dst=dest) + return dest + + def set_value(self, name, src): + self.value[:] = src + + def set_value_at_indices(self, name, inds, src): + self.value[inds] = src + + +class Float32Model(DTypeModel): + pass + + +class Int32Model(DTypeModel): + def __init__(self): + super().__init__() + self.dtype = np.dtype("int32") + self.value = np.array((12, 24, 36), dtype=self.dtype) + + +class BooleanModel(DTypeModel): + def __init__(self): + super().__init__() + self.dtype = np.dtype("bool") + self.value = np.array((True, False, True), dtype=self.dtype) + + +class HugeModel(DTypeModel): + """Model which has value which does not fit in single message body + + Can be run from command line with + + ..code-block:: bash + + run-bmi-server --path $PWD/test --name fake_models.HugeModel --port 55555 --debug + """ + + def __init__(self): + super().__init__() + self.dtype = np.dtype("float64") + # Create value which is bigger than 4Mb + dimension = (3 * 4_000_000) // self.dtype.itemsize + 1000 + self.value = np.ones((dimension,), dtype=self.dtype) + + +class WithItemSizeZeroAndVarTypeFloat32Model(Float32Model): + def get_var_itemsize(self, name): + return 0 + + +class WithItemSizeZeroAndUnknownVarType(WithItemSizeZeroAndVarTypeFloat32Model): + def get_var_type(self, name): + return "real" diff --git a/python/tests/test_reserve.py b/python/tests/test_reserve.py new file mode 100644 index 0000000..f2182d0 --- /dev/null +++ b/python/tests/test_reserve.py @@ -0,0 +1,11 @@ +import numpy as np + +from remotebmi.reserve import reserve_values +from tests.fake_models import Float32Model + + +def test_reserve_values(): + model = Float32Model() + result = reserve_values(model, "somevar") + assert result.dtype == np.float32 + assert result.size == 3 diff --git a/r-server/R/server.R b/r-server/R/server.R deleted file mode 100644 index 2b92634..0000000 --- a/r-server/R/server.R +++ /dev/null @@ -1,45 +0,0 @@ - -library(fiery) -library(routr) -library(reqres) - -#source('https://github.com/eWaterCycle/grpc4bmi-examples/raw/master/walrus/walrus-bmi.r') -#model <- WalrusBmi$new() - -get_component_name <- function(request, response, keys, ...) { - response$status <- 200L - response$type <- 'application/json' - # TODO call model method - response$body <- list(name = "Some model name") - response$format(json = format_json()) - return(FALSE) -} - -get_output_var_names <- function(request, response, keys, ...) { - response$status <- 200L - response$type <- 'application/json' - # TODO call model method - response$body <- c("var1", "var2", "var3") - response$format(json = format_json()) - return(FALSE) -} - -bmi_initialize <- function(request, response, keys, ...) { - request$parse(json = parse_json()) - model$bmi_initialize(request$body$config_file) - response$status <- 201L - return(FALSE) -} - -route <- Route$new() -route$add_handler('get', '/get_component_name', get_component_name) -route$add_handler('get', '/get_output_var_names', get_output_var_names) -route$add_handler('post', '/initialize', bmi_initialize) - -router <- RouteStack$new() -router$add_route(route, 'bmi') - -port = as.integer(Sys.getenv("BMI_PORT", 50051)) -app <- Fire$new(port=port) -app$attach(router) -app$ignite()