From d9438d5782cd4094557b2eaaa00f852408a73573 Mon Sep 17 00:00:00 2001 From: David Hart Date: Mon, 25 Nov 2024 10:04:59 -0700 Subject: [PATCH 1/3] feat: Add feature for casing This feature will prevent leaching above a certain depth in the model, modeling a casing. While this feature will not handle vertical leaching once a casing shoe has been fully exposed, it does provide the ability to model leaching directly below a shoe without using a kludge of putting a blanket at the shoe depth which causes other model artifacts. --- src/ext_modules/libsansmic/libsansmic.cpp | 149 +++++++++++++++------- src/ext_modules/libsansmic/libsansmic.hpp | 4 + src/ext_modules/libsansmic/model.cpp | 9 +- src/ext_modules/libsansmic/scenario.cpp | 2 + src/python/sansmic/model.py | 9 +- 5 files changed, 122 insertions(+), 51 deletions(-) diff --git a/src/ext_modules/libsansmic/libsansmic.cpp b/src/ext_modules/libsansmic/libsansmic.cpp index 75cd7f6..e3992f0 100644 --- a/src/ext_modules/libsansmic/libsansmic.cpp +++ b/src/ext_modules/libsansmic/libsansmic.cpp @@ -98,7 +98,9 @@ PYBIND11_MODULE(libsansmic, m) { .def_readwrite( "well_separation", &sansmic::Scenario::well_separation, "float : Separation between coallescing wells, default is 0.0 feet") - + .def_readwrite("casing_shoe_depth", &sansmic::Scenario::casing_shoe_depth, + "float: Depth of casing shoe, above which no leaching " + "will occur, default is 0.0 feet ( = off)") .def_readwrite("radius_vector", &sansmic::Scenario::geom_radii, "list[float] : radius at each node, len=" "(num_cells+1)") @@ -220,58 +222,109 @@ PYBIND11_MODULE(libsansmic, m) { .def("_get_current_state", &sansmic::Model::get_current_state, "CResults : get the model status results right now") .def("_get_stages", &sansmic::Model::get_stages, - "list[CStage] : get a list of all the stages"); + "list[CStage] : get a list of all the stages") + .def_readwrite("debug", &sansmic::Model::debug) + ; py::class_(m, "CResults", "See :class:`sansmic.model.Results`.") .def(py::init<>()) - .def_readwrite("r_0", &sansmic::Results::r_0, "list[float] : initial radius (ft)") - .def_readwrite("z_0", &sansmic::Results::z_0, "list[float] : depth (ft MD)") - .def_readwrite("h_0", &sansmic::Results::h_0, "list[float] : height (ft above original TD)") + .def_readwrite("r_0", &sansmic::Results::r_0, + "list[float] : initial radius (ft)") + .def_readwrite("z_0", &sansmic::Results::z_0, + "list[float] : depth (ft MD)") + .def_readwrite("h_0", &sansmic::Results::h_0, + "list[float] : height (ft above original TD)") .def_readwrite("step", &sansmic::Results::step, "list[int] : step number") - .def_readwrite("stage", &sansmic::Results::stage,"list[int] : stage number") - .def_readwrite("phase", &sansmic::Results::phase, "list[int] : active/passive phase") - .def_readwrite("injCell", &sansmic::Results::injCell,"list[int] : injection cell index") - .def_readwrite("prodCell", &sansmic::Results::prodCell, "list[int] : production cell index") - .def_readwrite("obiCell", &sansmic::Results::obiCell, "list[int] : obi cell index") - .def_readwrite("plumCell", &sansmic::Results::plmCell, "list[int] : plume cell index") + .def_readwrite("stage", &sansmic::Results::stage, + "list[int] : stage number") + .def_readwrite("phase", &sansmic::Results::phase, + "list[int] : active/passive phase") + .def_readwrite("injCell", &sansmic::Results::injCell, + "list[int] : injection cell index") + .def_readwrite("prodCell", &sansmic::Results::prodCell, + "list[int] : production cell index") + .def_readwrite("obiCell", &sansmic::Results::obiCell, + "list[int] : obi cell index") + .def_readwrite("plumCell", &sansmic::Results::plmCell, + "list[int] : plume cell index") .def_readwrite("t", &sansmic::Results::t, "list[float] : time (h)") - .def_readwrite("err", &sansmic::Results::err, "list[float] : convergence factor") - .def_readwrite("z_obi", &sansmic::Results::z_obi, "list[float] : obi depth (ft MD)") - .def_readwrite("z_inj", &sansmic::Results::z_inj, "list[float] : injection depth (ft MD)") - .def_readwrite("z_prod", &sansmic::Results::z_prod, "list[float] : production depth (ft MD)") - .def_readwrite("z_plm", &sansmic::Results::z_plm, "list[float] : plume stagnation depth (ft MD)") - .def_readwrite("z_insol", &sansmic::Results::z_insol, "list[float] : insoluble-based current TD (ft MD)") - .def_readwrite("h_insol", &sansmic::Results::h_insol, "list[float] : insoluble material height (ft)") - .def_readwrite("l_jet", &sansmic::Results::l_jet, "list[float] : jet length (ft)") - .def_readwrite("r_jet", &sansmic::Results::r_jet, "list[float] : jet radius (ft)") - .def_readwrite("u_jet", &sansmic::Results::u_jet, "list[float] : jet velocity (ft/s)") - .def_readwrite("V_injTot", &sansmic::Results::V_injTot, "list[float] :total injection volume (bbl)") - .def_readwrite("V_fillTot", &sansmic::Results::V_fillTot, "list[float] : total fill volume (bbl)") - .def_readwrite("V_cavTot", &sansmic::Results::V_cavTot, "list[float] : total cavern volume (bbl)") - .def_readwrite("V_insolTot", &sansmic::Results::V_insolTot, "list[float] : total insolubles deposited volume (bbl)") - .def_readwrite("V_insolVent", &sansmic::Results::V_insolVent, "list[float] : total insolubles vented volume (bbl)") - .def_readwrite("Q_out", &sansmic::Results::Q_out, "list[float] : instantaneous outflow rate (bbl/h)") - .def_readwrite("sg_out", &sansmic::Results::sg_out, "list[float] : instantaneous outflow concentration (sg)") - .def_readwrite("sg_cavAve", &sansmic::Results::sg_cavAve, "list[float] : instantaneous average cavern brine concentration (sg)") - .def_readwrite("dt", &sansmic::Results::dt, "list[float] : solver timestep (h)") - .def_readwrite("r", &sansmic::Results::r_cav, "list[list[float]] : cavern radius (ft)") - .def_readwrite("dr_0", &sansmic::Results::dr_cav, "list[list[float]] : change in cavern radius (ft)") - .def_readwrite("sg", &sansmic::Results::sg, "list[list[float]] : brine concentration (sg)") - .def_readwrite("theta", &sansmic::Results::theta, "list[list[float]] : wall angle (deg)") - .def_readwrite("Q_inj", &sansmic::Results::Q_inj, "list[list[float]] : injection rate (bbl/h)") - .def_readwrite("V", &sansmic::Results::V, "list[list[float]] : cell volume (bbl)") - .def_readwrite("f_dis", &sansmic::Results::f_dis, "list[list[float]] : dissolution factor (:1)") - .def_readwrite("f_flag", &sansmic::Results::f_flag, "list[list[float]] : dissolution flag (status)") - .def_readwrite("xincl", &sansmic::Results::xincl, "list[list[float]] : debug") + .def_readwrite("err", &sansmic::Results::err, + "list[float] : convergence factor") + .def_readwrite("z_obi", &sansmic::Results::z_obi, + "list[float] : obi depth (ft MD)") + .def_readwrite("z_inj", &sansmic::Results::z_inj, + "list[float] : injection depth (ft MD)") + .def_readwrite("z_prod", &sansmic::Results::z_prod, + "list[float] : production depth (ft MD)") + .def_readwrite("z_plm", &sansmic::Results::z_plm, + "list[float] : plume stagnation depth (ft MD)") + .def_readwrite("z_insol", &sansmic::Results::z_insol, + "list[float] : insoluble-based current TD (ft MD)") + .def_readwrite("h_insol", &sansmic::Results::h_insol, + "list[float] : insoluble material height (ft)") + .def_readwrite("l_jet", &sansmic::Results::l_jet, + "list[float] : jet length (ft)") + .def_readwrite("r_jet", &sansmic::Results::r_jet, + "list[float] : jet radius (ft)") + .def_readwrite("u_jet", &sansmic::Results::u_jet, + "list[float] : jet velocity (ft/s)") + .def_readwrite("V_injTot", &sansmic::Results::V_injTot, + "list[float] :total injection volume (bbl)") + .def_readwrite("V_fillTot", &sansmic::Results::V_fillTot, + "list[float] : total fill volume (bbl)") + .def_readwrite("V_cavTot", &sansmic::Results::V_cavTot, + "list[float] : total cavern volume (bbl)") + .def_readwrite("V_insolTot", &sansmic::Results::V_insolTot, + "list[float] : total insolubles deposited volume (bbl)") + .def_readwrite("V_insolVent", &sansmic::Results::V_insolVent, + "list[float] : total insolubles vented volume (bbl)") + .def_readwrite("Q_out", &sansmic::Results::Q_out, + "list[float] : instantaneous outflow rate (bbl/h)") + .def_readwrite("sg_out", &sansmic::Results::sg_out, + "list[float] : instantaneous outflow concentration (sg)") + .def_readwrite( + "sg_cavAve", &sansmic::Results::sg_cavAve, + "list[float] : instantaneous average cavern brine concentration (sg)") + .def_readwrite("dt", &sansmic::Results::dt, + "list[float] : solver timestep (h)") + .def_readwrite("r", &sansmic::Results::r_cav, + "list[list[float]] : cavern radius (ft)") + .def_readwrite("dr_0", &sansmic::Results::dr_cav, + "list[list[float]] : change in cavern radius (ft)") + .def_readwrite("sg", &sansmic::Results::sg, + "list[list[float]] : brine concentration (sg)") + .def_readwrite("theta", &sansmic::Results::theta, + "list[list[float]] : wall angle (deg)") + .def_readwrite("Q_inj", &sansmic::Results::Q_inj, + "list[list[float]] : injection rate (bbl/h)") + .def_readwrite("V", &sansmic::Results::V, + "list[list[float]] : cell volume (bbl)") + .def_readwrite("f_dis", &sansmic::Results::f_dis, + "list[list[float]] : dissolution factor (:1)") + .def_readwrite("f_flag", &sansmic::Results::f_flag, + "list[list[float]] : dissolution flag (status)") + .def_readwrite("xincl", &sansmic::Results::xincl, + "list[list[float]] : debug") .def_readwrite("amd", &sansmic::Results::amd, "list[list[float]] : debug") - .def_readwrite("D_coeff", &sansmic::Results::D_coeff, "list[list[float]] : diffusion coefficient") - .def_readwrite("dC_dz", &sansmic::Results::dC_dz, "list[list[float]] : vertical diffusion rate (sg/ )") - .def_readwrite("C_old", &sansmic::Results::C_old, "list[list[float]] : previous timestep concentration (sg)") - .def_readwrite("C_new", &sansmic::Results::C_new, "list[list[float]] : current timestep concentration (sg)") - .def_readwrite("dC", &sansmic::Results::dC_dt, "list[list[float]] : rate of change in concentration (sg/h)") - .def_readwrite("dr", &sansmic::Results::dr_dt, "list[list[float]] : rate of change in cavern radius (ft/h)") - .def_readwrite("C_plm", &sansmic::Results::C_plm, "list[list[float]] : plume concentration (sg)") - .def_readwrite("u_plm", &sansmic::Results::u_plm, "list[list[float]] : plume velocity (ft/s)") - .def_readwrite("r_plm", &sansmic::Results::r_plm, "list[list[float]] : plume radius (ft)"); + .def_readwrite("D_coeff", &sansmic::Results::D_coeff, + "list[list[float]] : diffusion coefficient") + .def_readwrite("dC_dz", &sansmic::Results::dC_dz, + "list[list[float]] : vertical diffusion rate (sg/ )") + .def_readwrite("C_old", &sansmic::Results::C_old, + "list[list[float]] : previous timestep concentration (sg)") + .def_readwrite("C_new", &sansmic::Results::C_new, + "list[list[float]] : current timestep concentration (sg)") + .def_readwrite( + "dC", &sansmic::Results::dC_dt, + "list[list[float]] : rate of change in concentration (sg/h)") + .def_readwrite( + "dr", &sansmic::Results::dr_dt, + "list[list[float]] : rate of change in cavern radius (ft/h)") + .def_readwrite("C_plm", &sansmic::Results::C_plm, + "list[list[float]] : plume concentration (sg)") + .def_readwrite("u_plm", &sansmic::Results::u_plm, + "list[list[float]] : plume velocity (ft/s)") + .def_readwrite("r_plm", &sansmic::Results::r_plm, + "list[list[float]] : plume radius (ft)"); } diff --git a/src/ext_modules/libsansmic/libsansmic.hpp b/src/ext_modules/libsansmic/libsansmic.hpp index 8b66541..133511c 100644 --- a/src/ext_modules/libsansmic/libsansmic.hpp +++ b/src/ext_modules/libsansmic/libsansmic.hpp @@ -166,6 +166,8 @@ struct Scenario { double relative_error; //!< the relative tolerance for the ODE solver double absolute_error; //!< the absolute tolerance for the ODE solver + double casing_shoe_depth; //!< the depth of the casing shoe, above which no leaching occurs + // deprecated options int coallescing_wells; //!< the number of identical wells coallescing double well_separation; //!< the separation between coallescing wells @@ -391,6 +393,7 @@ class BaseModel { void set_use_outfile(bool use_file); Results get_results(void); Results get_current_state(void); + bool debug = false; protected: string prefix; //!< output file prefix @@ -615,6 +618,7 @@ class Model : public BaseModel { double z_obi_stop; // OBI-final (for stage termination criteria) double z_TD0; //!< bottom of the cavern double z_ullage; // + double z_lccs; //!< lowest casing shoe int i_obi; // temp blanket cell index int i_obiOld; // temp blanket cell index diff --git a/src/ext_modules/libsansmic/model.cpp b/src/ext_modules/libsansmic/model.cpp index 345b74c..6d0cfca 100644 --- a/src/ext_modules/libsansmic/model.cpp +++ b/src/ext_modules/libsansmic/model.cpp @@ -96,6 +96,7 @@ void sansmic::Model::init_vars() { n_wells = 1; z_TD0 = 0; + z_lccs = 0; h_max = -1; h_insol = 0.0; h_obi = 0.0; @@ -184,9 +185,11 @@ void sansmic::Model::configure(Scenario scenario) { h_uso = scenario.ullage_standoff; dataFmt = scenario.geometry_format; stages = scenario.stages; + z_lccs = scenario.casing_shoe_depth; open_outfiles(false); if (verbosity > 3) scenario.debug_log(fileLog); + scenario.debug_log(fileLog); dz = h_max / double(n_cells); diffCoeff = D_mol; @@ -270,6 +273,7 @@ void sansmic::Model::configure(Scenario scenario) { theta = atan(tanTheta[i]); cosTheta[i] = cos(theta); h_cav[i + 1] = h_cav[i] + dz; + z_cav[i + 1] = z_cav[1] - h_cav[i + 1]; results.h_0[i] = h_cav[i - 1] + dz; depdkr[i] = z_cav[1] - h_cav[i]; results.z_0[i - 1] = z_cav[1] - h_cav[i]; @@ -725,10 +729,10 @@ int sansmic::Model::leach() { // calculate wall angle correction factor slope(jetPlumeCell); f_dis_prt[jetPlumeCell] = f_dis[jetPlumeCell]; - // evaluate eqn 4.1 and multiple by dis factor at jet cell dr_dt = salt->recession_rate(C_cav[jetPlumeCell]) * 60.0 * x_incl[jetPlumeCell] * f_dis[jetPlumeCell]; + if (z_cav[jetPlumeCell] <= z_lccs) dr_dt = 0.0; dr_prt[jetPlumeCell] = dr_dt * dt; V_saltRemove[jetPlumeCell] = 2.0 * dr_dt * r_cav[jetPlumeCell] * dz * dt * C_wall; @@ -739,6 +743,7 @@ int sansmic::Model::leach() { slope(i); f_dis_prt[i] = f_dis[i]; dr_dt = salt->recession_rate(C_cav[i]) * 60.0 * x_incl[i] * f_dis[i]; + if (z_cav[i] <= z_lccs) dr_dt = 0.0; dr_prt[i] = dr_dt * dt; V_saltRemove[i] = 2.0 * dr_dt * r_cav[i] * dz * dt * C_wall; m_saltRemove = m_saltRemove + V_saltRemove[i]; @@ -770,8 +775,8 @@ int sansmic::Model::leach() { im = max(i - 1, 1); slope(i); f_dis_prt[i] = f_dis[i]; - dr_dt = salt->recession_rate(C_cav[i]) * 60.0 * f_dis[i]; + if (z_cav[i] <= z_lccs) dr_dt = 0.0; dr_prt[i] = dr_dt * dt * x_incl[i]; volRemoved = diff --git a/src/ext_modules/libsansmic/scenario.cpp b/src/ext_modules/libsansmic/scenario.cpp index aa8bcf7..2d15492 100644 --- a/src/ext_modules/libsansmic/scenario.cpp +++ b/src/ext_modules/libsansmic/scenario.cpp @@ -45,6 +45,7 @@ sansmic::Scenario::Scenario() { dissolution_factor = 1.0; max_brine_sg = 1.2019; solid_density = 2.16; + casing_shoe_depth = 0.0; } /** @@ -102,6 +103,7 @@ void sansmic::Scenario::debug_log(ofstream &fout) { fout << " dissolution-factor = " << dissolution_factor << std::endl; fout << " salt-max-brine-sg = " << max_brine_sg << std::endl; fout << " salt-solid-density = " << solid_density << std::endl; + fout << " casing-shoe-depth = " << casing_shoe_depth << std::endl; fout << " stages:" << std::endl; for (int i = 0; i < stages.size(); i++) stages[i].debug_log(fout); } diff --git a/src/python/sansmic/model.py b/src/python/sansmic/model.py index c719c0f..758f703 100644 --- a/src/python/sansmic/model.py +++ b/src/python/sansmic/model.py @@ -166,6 +166,8 @@ class AdvancedOptions: """Eddy coefficient; CScenario default is 1.142e5""" diffusion_beta: float = None # 0.147 """Diffusion beta coefficient; default is 0.147""" + casing_shoe_depth: float = None # 0.0 + """Depth of the casing shoe; default is 0.0 (off)""" def __setattr__(self, name, value): if isinstance(value, str) and value.strip() == "": @@ -1002,6 +1004,9 @@ def _to_cscenario(self): self.advanced.temperature_model_version ) + if self.advanced.casing_shoe_depth is not None: + cscenario.casing_shoe_depth = self.advanced.casing_shoe_depth + # TODO: This needs to be far more robust if self.geometry_format is GeometryFormat.RADIUS_LIST and isinstance( self.geometry_data, str @@ -1249,7 +1254,7 @@ def close(self): self._is_finalized = False self._is_initialized = False - def run_sim(self): + def run_sim(self, debug=False): """Run the complete simulation; requires the Simulator to have been opened first.""" if self._cmodel is None: @@ -1258,6 +1263,8 @@ def run_sim(self): raise RuntimeError("The simulation is already running in stepwise mode") self._is_finalized = False self._is_initialized = True + if debug: + self._cmodel.debug = True self._cmodel.run_sim() self._has_run = True self._is_initialized = True From 7f3ab312909400ad5d90bbdd557d2ab8fb987c4f Mon Sep 17 00:00:00 2001 From: David Hart Date: Wed, 27 Nov 2024 18:51:47 -0700 Subject: [PATCH 2/3] build(docs): update conf.py for examples --- docs/conf.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 35e3c23..9e540c4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -241,7 +241,10 @@ "use_edit_page_button": False, "primary_sidebar_end": ["indices.html"], "show_toc_level": 2, - # "secondary_sidebar_items": ["page-toc"], #["page-toc", "edit-this-page", "sourcelink"], + "secondary_sidebar_items": { + "**": ["page-toc", "sourcelink"], + "examples/**": [], + }, "navbar_start": [ "navbar-logo", ], @@ -250,3 +253,6 @@ "navbar-icon-links", ], } +nbsphinx_thumbnails = { + "basic-example/basic": "/_static/basic-thumbnail.png", +} From e26fa046037e222470bbec0d3567193b9eabfbc3 Mon Sep 17 00:00:00 2001 From: David Hart Date: Mon, 16 Dec 2024 00:00:34 -0700 Subject: [PATCH 3/3] refactor: Move enums into separate package for better documentation --- CONTRIBUTING.md | 2 +- README.md | 14 +- docs/um/outputs.rst | 157 ++++++++++++++ docs/um/scenarios.rst | 2 +- docs/userman.rst | 1 - src/python/sansmic/__init__.py | 2 +- src/python/sansmic/enums.py | 207 ++++++++++++++++++ src/python/sansmic/io.py | 11 +- src/python/sansmic/model.py | 382 ++++++++++++++++++++------------- tests/test_app.py | 4 +- tests/test_io.py | 4 +- tests/test_model.py | 2 +- 12 files changed, 618 insertions(+), 170 deletions(-) create mode 100644 docs/um/outputs.rst create mode 100644 src/python/sansmic/enums.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c7b9aa..319b9cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,7 +106,7 @@ The checks we perform are the following: ### VS Code -Our IDE of choice is [VS Code][vscode]. Download and install it, and then +Our IDE of choice is [Visual Studio Code][vscode] (VS Code). Download and install it, and then follow the instructions below to get it set up. [vscode]: https://code.visualstudio.com/ diff --git a/README.md b/README.md index 940339e..b25760a 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,11 @@ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9399/badge)](https://www.bestpractices.dev/projects/9399) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/sandialabs/sansmic/badge)](https://scorecard.dev/viewer/?uri=github.com/sandialabs/sansmic) -![Lines of code](https://sloc.xyz/github/sandialabs/sansmic/?category=code) [![Test Matrix](https://github.com/sandialabs/sansmic/actions/workflows/test-matrix.yml/badge.svg?branch=main)](https://github.com/sandialabs/sansmic/actions/workflows/test-matrix.yml) [![codecov](https://codecov.io/github/sandialabs/sansmic/graph/badge.svg?token=oDeMIUHoqg)](https://codecov.io/github/sandialabs/sansmic) [![Deploy Sphinx documentation](https://github.com/sandialabs/sansmic/actions/workflows/gh-pages.yml/badge.svg?branch=main)](https://github.com/sandialabs/sansmic/actions/workflows/gh-pages.yml) [![pypi](https://img.shields.io/pypi/v/sansmic.svg?maxAge=3600)](https://pypi.org/project/sansmic/) -[![Downloads](https://static.pepy.tech/badge/sansmic)](https://pepy.tech/project/sansmic) ## SANSMIC @@ -34,7 +32,7 @@ The sansmic package requires Python 3.9 or greater and at least the numpy and pandas packages. Installation can be accomplished most easily by using the PyPI. -It can also be installed by downloading a wheel from the +It can also be installed by downloading a wheel from the [releases](https://github.com/sandialabs/sansmic/releases) in this repository, or by cloning this repository and building it yourself. Finally, a standalone executable has been created that @@ -44,12 +42,18 @@ on the right side of the page). #### PyPI wheels +![PyPI - Wheel](https://img.shields.io/pypi/wheel/sansmic?logo=pypi&logoColor=white) + To install a pre-compiled version of sansmic, use the pip command python -m pip install sansmic #### Build from source +![Git](https://img.shields.io/badge/Git-F05032?logo=git&logoColor=fff) +![C++](https://img.shields.io/badge/C++-%2300599C.svg?logo=c%2B%2B&logoColor=fff) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sansmic?logo=python&logoColor=fff&label=cpython) + To download and build from source, you should clone the repository (or, fork sansmic and clone your repository) and then install using the editable (``-e``) flag. @@ -85,7 +89,9 @@ If you would like to contribute, please read the [guide to contributing](CONTRIBUTING.md). ### License & Copyright -See [LICENSE](LICENSE) and [COPYRIGHT](COPYRIGHT.md). +![License - BSD-3-Clause](https://img.shields.io/pypi/l/sansmic?label=LICENSE) + +See the [LICENSE](LICENSE) and [COPYRIGHT](COPYRIGHT.md) files with the code. [Sandia National Laboratories](https://www.sandia.gov) is a multimission laboratory managed diff --git a/docs/um/outputs.rst b/docs/um/outputs.rst new file mode 100644 index 0000000..70f9467 --- /dev/null +++ b/docs/um/outputs.rst @@ -0,0 +1,157 @@ +Outputs +======= + + + + +Files created +------------------- + + + + + + +File formats +------------ + + +HDF5-formatted files +~~~~~~~~~~~~~~~~~~~~ + +.. rubric:: Notation and naming + +``\-- item`` + an item is either a group (folder) or a data object (array) +``@-- attribute`` + an attribute is metadata associated with an item +``\-- data: [dim1][dim2]`` + data is a an N-D array of type ```` (usually string, float, or integer) + with ``dim1`` rows and, optionally, ``dim2`` columns. +``(identifier)`` + an identifier surrounded by ``( )`` indicates that a group or data is optional +```` + an identifier surrounded by ``< >`` indicates that the group is indexed, e.g., 1, 2, ... + + +.. rubric:: General organization + + +.. rubric:: Spatially referenced data + + +.. rubric:: Time dependent data + + + +.. rubric:: File layout + + +.. plantuml:: + + root level + \-- sansmic + \-- cavern + \-- scenario + \-- + \-- config + \-- simulation + +.. verbatim:: + + sansmic + @-- version: Integer[2] + \-- author + | @-- name: String[] + | @-- (email: String[]) + \-- creator + @-- name: String[] + @-- version: String[] + + cavern + @-- name: String[] + @-- wells: String[variable] + \-- (depthzero) + | @-- crs: String[] + | \-- elev: Float[] + \-- (centerline) + | @-- crs: String[] + | @-- axis: String[2] + | \-- coords: Float[2] + \-- geometry: Float[variable][by-format] + | @-- format: String[] + | @-- num-cells: Integer[] + | @-- depth-range: Float[2] + + config + @-- title: String[] + @-- comments: String[] + @-- units: String[] + \-- casings + | \-- : Float[2] + \-- solver + | @-- timestep: Float[] + | @-- save-frequency: Integer[] + | @-- absolue-error: Float[] + | @-- relative-error: Float[] + | ... + \-- stages + \-- + @-- title: String[] + + simulation + \-- node_depth: Float[nn] + \-- node_height: Float[nn] + \-- node_radius0: Float[nn] + \-- time_hours: Float[ns] + \-- time_days: Float[ns] + \-- state + | \-- step_id: Integer[ns] + | \-- stage_id: Integer[ns] + | \-- run_mode: Integer[ns] + | \-- inj_phase: Integer[ns] + | \-- inj_cell: Integer[ns] + | \-- prod_cell: Integer[ns] + | \-- plume_cell: Integer[ns] + | \-- obi_cell: Integer[ns] + | \-- convergence: Float[ns] + | \-- stepsize: Float[ns] + \-- instantaneous + | \-- inj_depth: Float[ns] + | \-- prod_depth: Float[ns] + | \-- plume_depth: Float[ns] + | \-- obi_depth: Float[ns] + | \-- flow_out: Float[ns] + | \-- flow_in: Float[ns] + | \-- jet_length: Float[ns] + | \-- jet_velocity: Float[ns] + | \-- jet_radius: Float[ns] + | \-- inj_sg: Float[ns] + | \-- prod_sg: Float[ns] + | \-- cavern_sg: Float[ns] + | \-- cavern_volume: Float[ns] + \-- cumulative + | \-- insol_top_depth: Float[ns] + | \-- insol_height: Float[ns] + | \-- inj_volume: Float[ns] + | \-- prod_volume: Float[ns] + | \-- fill_volume: Float[ns] + | \-- insol_volume: Float[ns] + | \-- vented_volume: Float[ns] + \-- nodes + \-- radius: Float[ns][nn] + \-- dr0: Float[ns][nn] + \-- dr_dt: Float[ns][nn] + \-- theta: Float[ns][nn] + \-- xincl: Float[ns][nn] + \-- volume: Float[ns][nn] + \-- density: Float[ns][nn] + \-- dC_dt: Float[ns][nn] + \-- dC_dz: Float[ns][nn] + \-- dis_flag: Integer[ns][nn] + \-- dis_factor: Float[ns][nn] + \-- diff_coeff: Float[ns][nn] + \-- plume_radius: Float[ns][nn] + \-- plume_velocity: Float[ns][nn] + \-- plume_density: Float[ns][nn] + \-- inflow: Float[ns][nn] diff --git a/docs/um/scenarios.rst b/docs/um/scenarios.rst index 272e1d2..98a9ec8 100644 --- a/docs/um/scenarios.rst +++ b/docs/um/scenarios.rst @@ -457,7 +457,7 @@ number of stages. A stage **requires** a title key. way as an hour is for 'hourly'. *This is the recommended option for most simulations.* - "bystage" + "stage" Only save results at the end of each injection and each rest period. This is the most efficient method in terms of storage and processing time, but provides little data for debugging or diff --git a/docs/userman.rst b/docs/userman.rst index 4ce2197..898b21e 100644 --- a/docs/userman.rst +++ b/docs/userman.rst @@ -3,7 +3,6 @@ User Guide .. toctree:: :maxdepth: 1 - :hidden: um/intro um/getting-started diff --git a/src/python/sansmic/__init__.py b/src/python/sansmic/__init__.py index cba1152..d96b8f9 100644 --- a/src/python/sansmic/__init__.py +++ b/src/python/sansmic/__init__.py @@ -9,4 +9,4 @@ """SANSMIC package""" from ._version import __version__, copyright, license -from . import app, empirical, io, model +from . import app, empirical, enums, io, model diff --git a/src/python/sansmic/enums.py b/src/python/sansmic/enums.py new file mode 100644 index 0000000..976eabb --- /dev/null +++ b/src/python/sansmic/enums.py @@ -0,0 +1,207 @@ +# coding: utf-8 +# +# Copyright (c) 2024 National Technology and Engineering Solutions of +# Sandia, LLC. Under the terms of Contract DE-NA0003525 with NTESS, +# the U.S. Government retains certain rights in this software. +# +# SPDX-License-Identifier: BSD-3-Clause. + +"""Enums for sansmic configuration options. + +Also includes a metaclass (:class:`CaseNormalizedEnumType`) and a +subclass of :class:`enum.IntEnum` that are used to normalize case +of members of the enum to `UPPER_CASE_UNDERSCORED` when used in code +and to `lower-case-dashed` when output as a string value. +""" + +from enum import EnumType, IntEnum, Enum +from fractions import Fraction + + +class CaseNormalizedEnumType(EnumType): + """Change to a case-normalized name handling.""" + + def __getitem__(cls, name): + """ + Return the member matching `name`. + """ + return cls._member_map_[name.replace("-", "_").replace(" ", "_").upper()] + + def __setattr__(cls, name, value): + """ + Block attempts to reassign Enum members. + + A simple assignment to the class namespace only changes one of the + several possible ways to get an Enum member from the Enum class, + resulting in an inconsistent Enumeration. + """ + member_map = cls.__dict__.get("_member_map_", {}) + if name.upper().replace("-", "_").replace(" ", "_") in member_map: + raise AttributeError("cannot reassign member %r" % (name,)) + super().__setattr__(name, value) + + +class CaseNormalizedIntEnum(IntEnum, metaclass=CaseNormalizedEnumType): + """A case-normalized name handling subclass of IntEnum.""" + + def __str__(self): + return "%s" % (self._name_.replace("_", "-").lower()) + + +class CaseNormalizedEnum(Enum, metaclass=CaseNormalizedEnumType): + """A case-normalized name handling subclass of IntEnum.""" + + def __str__(self): + return "%s" % (self._name_.replace("_", "-").lower()) + + +class Units(CaseNormalizedIntEnum): + """The units that are used to define the scenario. + + Depths, heights, and cavern radii are in the first (larger) length unit. + Tubing radii are in the second (smaller) length unit. + Volumes and volumetric flow rates are in the third unit. + + Durations are in hours. Constant injection rates are in per day. + File-based injection rates are in per hour. + + WARNING: This class has been modified to be case and separator (-, _, and space) + insensitive. I.e., ``Units['ft-IN bBl']`` is + normalized and will return ``Units['FT_IN_BBL']``. + """ + + FT_IN_BBL = 1 + """foot/inch/barrel""" + FT_IN_FT3 = 2 + """foot/inch/cubic foot""" + M_CM_M3 = 3 + """meter/centimeter/cubic meter""" + + @classmethod + def inch(self): + """1 in ≔ 0.0254 m""" + return Fraction(254, 10000) + + @classmethod + def foot(self): + """1 ft ≔ 0.3048 m""" + return Fraction(3048, 10000) + + @classmethod + def cubic_foot(self): + """1 ft³ == 0.028316846592 m³""" + return Fraction(3048, 10000) ** 3 + + @classmethod + def barrel(self): + """1 bbl == 0.158987294928 m³""" + return 42 * 231 * Fraction(254, 10000) ** 3 + + @classmethod + def centimeter(self): + """1 cm ≔ 0.01 m""" + return Fraction(1, 100) + + +class StopCondition(CaseNormalizedIntEnum): + """Which stop condition should be used to end the simulation. + + WARNING: This class has been modified to be case and separator (-, _, and space) + insensitive. I.e., ``StopCondition['dePTH']`` is + normalized and will return ``StopCondition['DEPTH']``. + """ + + DEPTH = -1 + """Stop when the interface reaches a specified height above the cavern floor""" + DURATION = 0 + """Stop only based on duration""" + VOLUME = 1 + """Stop when the total cavern volume reaches a specified value.""" + + def __getitem__(cls, name): + """ + Return the member matching `name`. + """ + return cls._member_map_[name.replace("-", "_").replace(" ", "_").upper()] + + +class GeometryFormat(CaseNormalizedIntEnum): + """The format for the initial cavern geometry. + + WARNING: This class has been modified to be case and separator (-, _, and space) + insensitive. I.e., ``GeometryFormat['radius LISt']`` is + normalized and will return ``GeometryFormat['RADIUS_LIST']``. + """ + + RADIUS_LIST = 0 + """The radius is provided from the bottom of the cavern to the top; + :attr:`~Scenario.num_cells` + 1 values, equally spaced""" + VOLUME_LIST = 1 + VOLUME_TABLE = -1 + RADIUS_TABLE = 2 + LAYER_CAKE = 5 + """The geometry is provided in a 'layer-cake' style LAS file""" + + def __getitem__(cls, name): + """ + Return the member matching `name`. + """ + return cls._member_map_[name.replace("-", "_").replace(" ", "_").upper()] + + +class SimulationMode(CaseNormalizedIntEnum): + """The simulation mode determines which options are active for injection. + + WARNING: This class has been modified to be case and separator (-, _, and space) + insensitive. I.e., ``SimulationMode['leACH-FiLL']`` is + normalized and will return ``SimulationMode['LEACH_FILL']``. + """ + + ORDINARY = 0 + """Ordinary leaching, with raw water or undersaturated brine injected through + the inner tubing or outer casing and brine produced from the other.""" + WITHDRAWAL = 1 + """Withdrawal leach, with brine injected through the suspended tubing (hanging + string) and product produced from top of cavern.""" + LEACH_FILL = 2 + """Simultaneous leaching (water/brine injection and brine production) and + product injection from the top.""" + + def __getitem__(cls, name): + """ + Return the member matching `name`. + """ + return cls._member_map_[name.replace("-", "_").replace(" ", "_").upper()] + + +class RateScheduleType(CaseNormalizedIntEnum): + """The way that the rate of injection is specified. + + WARNING: This class has been modified to be case and separator (-, _, and space) + insensitive. I.e., ``RateScheduleType['Constant Rate']`` is + normalized and will return ``RateScheduleType['CONSTANT_RATE']``. + """ + + CONSTANT_RATE = 1 + """Injection occurs at a constant rate of volume :class:`Units` per day.""" + DATAFILE = 2 + """Injection rate is specified in :class:`Units` per hour in a file.""" + + def __getitem__(cls, name): + """ + Return the member matching `name`. + """ + return cls._member_map_[name.replace("-", "_").replace(" ", "_").upper()] + + +class SaveFrequency(CaseNormalizedEnum): + """The frequency at which data should be saved by the underlying C++ module.""" + + HOURLY = 1 + """Save results as close to every hour as machine precision will allow.""" + DAILY = 2 + """Save results as close to every day as machine precision will allow.""" + STAGE = 3 + """Save results at the end of the injection phase and at the end of the stage (if rest-duration > 0).""" + BYSTAGE = STAGE + """To-be-deprecated alias""" diff --git a/src/python/sansmic/io.py b/src/python/sansmic/io.py index f8c6ae9..673c479 100644 --- a/src/python/sansmic/io.py +++ b/src/python/sansmic/io.py @@ -18,6 +18,8 @@ from pathlib import Path from typing import Union +from .enums import GeometryFormat, SimulationMode, StopCondition + if sys.version_info[1] < 11: import tomli as tomllib else: @@ -42,12 +44,9 @@ h5py = e from .model import ( - GeometryFormat, Results, Scenario, - SimulationMode, StageDefinition, - StopCondition, _OutDataBlock, _OutputData, ) @@ -410,12 +409,6 @@ def write_scenario(scenario: Scenario, filename: str, *, redundant=False, format elif isinstance(sdict[k], IntEnum): sdict[k] = sdict[k].name.lower().replace("_", "-") for ct, s in enumerate(sdict["stages"]): - if ( - not redundant - and scenario.stages[ct].stop_condition == StopCondition.DURATION - ): - del s["stop-condition"] - del s["stop-value"] keys = [k for k in s.keys()] for k in keys: if ( diff --git a/src/python/sansmic/model.py b/src/python/sansmic/model.py index 758f703..f6a4854 100644 --- a/src/python/sansmic/model.py +++ b/src/python/sansmic/model.py @@ -8,16 +8,24 @@ """The core python classes for SANSMIC.""" +import dataclasses import logging import warnings from dataclasses import InitVar, asdict, dataclass, field -from enum import IntEnum -from fractions import Fraction +from enum import Enum, EnumType, IntEnum from typing import Any, Dict, List, Literal, Union import numpy as np import pandas as pd +from sansmic.enums import ( + GeometryFormat, + SaveFrequency, + SimulationMode, + StopCondition, + Units, +) + try: import h5py except ImportError as e: @@ -42,97 +50,6 @@ def _rename_with_dash(orig: dict, new: dict): new[k] = v -class Units(IntEnum): - """The units that are used to define the scenario. - - Depths, heights, and cavern radii are in the first (larger) length unit. - Tubing radii are in the second (smaller) length unit. - Volumes and volumetric flow rates are in the third unit. - - Durations are in hours. Constant injection rates are in per day. - File-based injection rates are in per hour. - """ - - FT_IN_BBL = 1 - """foot/inch/barrel""" - FT_IN_FT3 = 2 - """foot/inch/cubic foot""" - M_CM_M3 = 3 - """meter/centimeter/cubic meter""" - - @classmethod - def inch(self): - """1 in ≔ 0.0254 m""" - return Fraction(254, 10000) - - @classmethod - def foot(self): - """1 ft ≔ 0.3048 m""" - return Fraction(3048, 10000) - - @classmethod - def cubic_foot(self): - """1 ft³ == 0.028316846592 m³""" - return Fraction(3048, 10000) ** 3 - - @classmethod - def barrel(self): - """1 bbl == 0.158987294928 m³""" - return 42 * 231 * Fraction(254, 10000) ** 3 - - @classmethod - def centimeter(self): - """1 cm ≔ 0.01 m""" - return Fraction(1, 100) - - -class StopCondition(IntEnum): - """Which stop condition should be used to end the simulation.""" - - DEPTH = -1 - """Stop when the interface reaches a specified height above the cavern floor""" - DURATION = 0 - """Stop only based on duration""" - VOLUME = 1 - """Stop when the total cavern volume reaches a specified value.""" - - -class GeometryFormat(IntEnum): - """The format for the initial cavern geometry.""" - - RADIUS_LIST = 0 - """The radius is provided from the bottom of the cavern to the top; - :attr:`~Scenario.num_cells` + 1 values, equally spaced""" - VOLUME_LIST = 1 - VOLUME_TABLE = -1 - RADIUS_TABLE = 2 - LAYER_CAKE = 5 - """The geometry is provided in a 'layer-cake' style LAS file""" - - -class SimulationMode(IntEnum): - """The simulation mode determines which options are active for injection.""" - - ORDINARY = 0 - """Ordinary leaching, with raw water or undersaturated brine injected through - the inner tubing or outer casing and brine produced from the other.""" - WITHDRAWAL = 1 - """Withdrawal leach, with brine injected through the suspended tubing (hanging - string) and product produced from top of cavern.""" - LEACH_FILL = 2 - """Simultaneous leaching (water/brine injection and brine production) and - product injection from the top.""" - - -class RateScheduleType(IntEnum): - """The way that the rate of injection is specified.""" - - CONSTANT_RATE = 1 - """Injection occurs at a constant rate of volume :class:`Units` per day.""" - DATAFILE = 2 - """Injection rate is specified in :class:`Units` per hour in a file.""" - - @dataclass class AdvancedOptions: """Advanced configuration options. Most of these can and should be left as None which @@ -169,6 +86,15 @@ class AdvancedOptions: casing_shoe_depth: float = None # 0.0 """Depth of the casing shoe; default is 0.0 (off)""" + def __iter__(self): + for k, f in self.__dataclass_fields__.items(): + if hasattr(self, k) and f._field_type != dataclasses._FIELD: + continue + v = getattr(self, k) + if v is None: + continue + yield k, v + def __setattr__(self, name, value): if isinstance(value, str) and value.strip() == "": value = None @@ -243,8 +169,8 @@ class StageDefinition: """The simulation mode used in this stage.""" solver_timestep: float = None """The solver timestep in hours.""" - save_frequency: Union[int, Literal["hourly", "daily", "bystage"]] = None - """The save frequency in number of timesteps, or one of "hourly", "daily", or "bystage", by default "bystage".""" + save_frequency: Union[int, SaveFrequency] = None + """The save frequency in number of timesteps, or one of "hourly", "daily", or "stage", by default "stage".""" injection_duration: float = None """The duration of the injection phase of the stage.""" rest_duration: float = None @@ -268,7 +194,7 @@ class StageDefinition: brine_injection_rate: Union[float, str] = 0 """The meaning of this field is based on the type of data that is provided (a value of 0 means off).""" - set_initial_conditions: bool = None + set_initial_conditions: bool = False """Unlink initial cavern brine gravity and interface level from previous stage. Automatically set to True for the first stage added to a model.""" set_cavern_sg: float = None @@ -307,6 +233,19 @@ class StageDefinition: "outer_csg_outside_diam", ] + def __iter__(self): + for k, f in self.__dataclass_fields__.items(): + if f._field_type is dataclasses._FIELD_INITVAR: + continue + try: + v = getattr(self, k) + except ValueError: + if "depth" in k: + v = getattr(self, k.replace("depth", "height")) + else: + raise + yield k, v + def __post_init__( self, defaults=None, @@ -317,19 +256,23 @@ def __post_init__( ): if defaults is None: - defaults = dict() - if not isinstance(defaults, dict): + defaults = None + self.__defaults = None + elif not isinstance(defaults, dict): raise TypeError("defaults must be a dictionary") - for k, v in defaults.items(): - k2 = k.strip().replace("-", "_").replace(" ", "_").replace(".", "_") - if k2 not in self.valid_default_keys: - logger.warning( # pragma: no cover - "Ignoring non-defaultable or unknown setting {} = {}".format( - k, repr(v) + else: + self.__defaults = defaults + for k, v in defaults.items(): + k2 = k.strip().replace("-", "_").replace(" ", "_").replace(".", "_") + if k2 not in self.valid_default_keys: + logger.warning( # pragma: no cover + "Ignoring non-defaultable or unknown setting {} = {}".format( + k, repr(v) + ) ) - ) - elif getattr(self, k2) is None: - setattr(self, k2, v) + # don't do this anymore since we are keeping the default dictionary linked + # elif getattr(self, k2) is None: + # setattr(self, k2, v) if isinstance(blanket_depth, (float, int, str)): self.brine_interface_depth = blanket_depth if isinstance(brine_injection_height, (float, int, str)): @@ -348,9 +291,7 @@ def __setattr__(self, name, value): if isinstance(value, int): value = SimulationMode(value) elif isinstance(value, str): - value = SimulationMode[ - value.upper().replace(" ", "_").replace("-", "_") - ] + value = SimulationMode[value] elif isinstance(value, _ext.CRunMode): value = SimulationMode(int(value)) else: @@ -363,18 +304,33 @@ def __setattr__(self, name, value): if isinstance(value, int): value = StopCondition(value) elif isinstance(value, str): - value = StopCondition[value.upper().replace(" ", "_").replace("-", "_")] + value = StopCondition[value] else: raise TypeError( "stop_condition cannot be of type {}".format(type(value)) ) + elif name == "save_frequency" and isinstance(value, str): + value = SaveFrequency[value] elif name == "set_cavern_sg" and value is not None and value < 1.0: value = None + elif name == "brine_interface_depth" and value == 0: + value = None # elif name == "set_initial_conditions" and value is False: # self.set_cavern_sg = None # self.brine_interface_depth = None super().__setattr__(name, value) + def _relink_defaults(self, scenario: "Scenario"): + if not isinstance(scenario, Scenario): + raise TypeError(f"Expected Scenario but got {type(scenario)}") + self.__defaults = scenario.defaults + + def squash_defaults(self): + if isinstance(self.__defaults, dict): + for k, v in self.__defaults.items(): + if k in self.__dataclass_fields__.keys() and getattr(self, k) == v: + setattr(self, k, None) + @property def blanket_depth(self) -> float: """Alias for :attr:`brine_interface_depth`""" @@ -433,7 +389,7 @@ def brine_interface_height(self) -> float: @brine_interface_height.setter def brine_interface_height(self, value): - if value is None: + if value is None or value == 0: self.brine_interface_depth = None else: self.brine_interface_depth = -abs(value) @@ -485,15 +441,35 @@ def to_dict(self, keep_empty: bool = False): the options dictionary """ - ret = dict() - _rename_with_dash(asdict(self), ret) - keys = list(ret.keys()) + ret1 = dict(self) + for k, v in ret1.items(): + if isinstance(v, Enum): + v = str(v) + ret1[k] = v + + keys = list(ret1.keys()) for k in keys: - if ret[k] is None: + if isinstance(self.__defaults, dict) and k in self.__defaults.keys(): + if self.__defaults[k] == ret1[k] or ret1[k] is None: + ret1[k] = None + if ( + not keep_empty + and k in self.__dataclass_fields__.keys() + and getattr(self, k) == self.__dataclass_fields__[k].default + ): + ret1[k] = None + if ret1[k] is None: if keep_empty: - ret[k] = "" + if self.__dataclass_fields__[k].default is None: + ret1[k] = "" + elif isinstance(self.__dataclass_fields__[k].default, Enum): + ret1[k] = str(self.__dataclass_fields__[k].default) + else: + ret1[k] = self.__dataclass_fields__[k].default else: - del ret[k] + del ret1[k] + ret = dict() + _rename_with_dash(ret1, ret) return ret def validate(self): @@ -602,6 +578,10 @@ def validate(self): SimulationMode.WITHDRAWAL, ] and self.inner_tbg_inside_diam is None + and ( + self.__defaults is None + or not "inner_tbg_inside_diam" in self.__defaults + ) ): raise TypeError( "Missing required 'inner_tbg_inside_diam' for {} simulation mode.".format( @@ -616,6 +596,10 @@ def validate(self): SimulationMode.WITHDRAWAL, ] and self.inner_tbg_outside_diam is None + and ( + self.__defaults is None + or not "inner_tbg_outside_diam" in self.__defaults + ) ): raise TypeError( "Missing required 'inner_tbg_outside_diam' for {} simulation mode.".format( @@ -625,6 +609,10 @@ def validate(self): if ( self.simulation_mode in [SimulationMode.ORDINARY, SimulationMode.LEACH_FILL] and self.outer_csg_inside_diam is None + and ( + self.__defaults is None + or not "outer_csg_inside_diam" in self.__defaults + ) ): raise TypeError( "Missing required 'outer_csg_inside_diam' for {} simulation mode.".format( @@ -634,6 +622,10 @@ def validate(self): if ( self.simulation_mode in [SimulationMode.ORDINARY, SimulationMode.LEACH_FILL] and self.outer_csg_outside_diam is None + and ( + self.__defaults is None + or not "outer_csg_outside_diam" in self.__defaults + ) ): raise TypeError( "Missing required 'outer_csg_outside_diam' for {} simulation mode.".format( @@ -667,8 +659,11 @@ def _to_cstage( self.validate() stage = _ext.CStage() - if defaults is None: - defaults = dict() + if defaults is not None: + raise DeprecationWarning( + "Passing the defaults by parameter will no longer be permitted in SANSMIC 1.1" + ) + defaults = self.__defaults stage.timestep = ( self.solver_timestep if self.solver_timestep @@ -681,12 +676,12 @@ def _to_cstage( else: stage.title = self.title stage.mode = _ext.CRunMode(int(self.simulation_mode)) - if isinstance(self.save_frequency, str): - if self.save_frequency == "hourly": + if isinstance(self.save_frequency, SaveFrequency): + if self.save_frequency == SaveFrequency.HOURLY: stage.print_interval = int(np.round(1 / stage.timestep)) - elif self.save_frequency == "daily": + elif self.save_frequency == SaveFrequency.DAILY: stage.print_interval = int(np.round(24.0 / stage.timestep)) - elif self.save_frequency == "bystage": + elif self.save_frequency == SaveFrequency.STAGE: stage.print_interval = 0 else: stage.print_interval = int(self.save_frequency) @@ -713,21 +708,57 @@ def _to_cstage( -self.brine_interface_depth if self.brine_interface_depth is not None else 0 ) stage.injection_rate = self.brine_injection_rate - stage.inn_tbg_inside_radius = self.inner_tbg_inside_diam / 2.0 - stage.inn_tbg_outside_radius = self.inner_tbg_outside_diam / 2.0 - stage.out_csg_inside_radius = ( - self.outer_csg_inside_diam / 2.0 - if self.outer_csg_inside_diam - else self.inner_tbg_inside_diam / 2.0 - ) - stage.out_csg_outside_radius = ( - self.outer_csg_outside_diam / 2.0 - if self.outer_csg_outside_diam - else self.inner_tbg_outside_diam / 2.0 - ) + defs = dict() + if self.__defaults is not None: + _rename_with_underscore(self.__defaults, defs) + try: + tbgID = ( + self.inner_tbg_inside_diam + if self.inner_tbg_inside_diam is not None + else defs["inner_tbg_inside_diam"] + ) + except KeyError: + raise ValueError( + "No value provided for inner_tbg_inside_diam, and no default value specified either" + ) + + try: + tbgOD = ( + self.inner_tbg_inside_diam + if self.inner_tbg_outside_diam is not None + else defs["inner_tbg_outside_diam"] + ) + except KeyError: + raise ValueError( + "No value provided for inner_tbg_outside_diam, and no default value specified either" + ) + + stage.inn_tbg_inside_radius = tbgID / 2.0 + stage.inn_tbg_outside_radius = tbgOD / 2.0 + + try: + csgID = ( + self.outer_csg_inside_diam + if self.outer_csg_inside_diam is not None + else defs["outer_csg_inside_diam"] + ) + except KeyError: + csgID = tbgID + + try: + csgOD = ( + self.outer_csg_outside_diam + if self.outer_csg_outside_diam is not None + else defs["outer_csg_outside_diam"] + ) + except KeyError: + csgOD = tbgOD + + stage.out_csg_inside_radius = csgID / 2.0 + stage.out_csg_outside_radius = csgOD / 2.0 + stage.injection_fluid_sg = self.brine_injection_sg stage.cavern_sg = self.set_cavern_sg if self.set_cavern_sg is not None else 0.0 - stage.timestep = self.solver_timestep stage.injection_duration = self.injection_duration stage.fill_rate = ( self.product_injection_rate @@ -768,6 +799,17 @@ class Scenario: stages: List[StageDefinition] = field(default=None) """The activity stages to simulate.""" + def __iter__(self): + for k in self.__dataclass_fields__.keys(): + v = getattr(self, k) + if isinstance(v, AdvancedOptions): + v = dict(v) + elif k == "stages": + for s in self.stages: + s._relink_defaults(self) + v = [dict(s) for s in v] + yield k, v + def __setattr__(self, name, value): """The setattr method is overloaded. IntEnum parameters are automatically converted from strings or integers and stages and @@ -780,9 +822,7 @@ def __setattr__(self, name, value): if isinstance(value, int): value = GeometryFormat(value) elif isinstance(value, str): - value = GeometryFormat[ - value.upper().replace(" ", "_").replace("-", "_") - ] + value = GeometryFormat[value] elif isinstance(value, _ext.CGeometryFormat): value = GeometryFormat(int(value)) else: @@ -791,7 +831,7 @@ def __setattr__(self, name, value): if isinstance(value, int): value = Units(value) elif isinstance(value, str): - value = Units[value.upper().replace(" ", "_").replace("-", "_")] + value = Units[value] else: TypeError("Units cannot be of type {}".format(type(value))) elif ( @@ -823,6 +863,14 @@ def __setattr__(self, name, value): else: TypeError("stages cannot set directly - please use add_stages") super().__setattr__(name, value) + if ( + name == "defaults" + and value is not None + and hasattr(self, "stages") + and self.stages is not None + ): + for s in self.stages: + s._relink_defaults(self) @classmethod def from_dict(cls, opts: dict) -> "Scenario": @@ -871,8 +919,10 @@ def to_dict(self, keep_empty: bool = False): the options dictionary """ - ret = dict() - _rename_with_dash(asdict(self), ret) + ret = dict(self) + for k, v in ret.items(): + if isinstance(v, IntEnum): + ret[k] = str(v) ret["advanced"] = self.advanced.to_dict(keep_empty) if len(ret["advanced"]) == 0: del ret["advanced"] @@ -892,15 +942,20 @@ def to_dict(self, keep_empty: bool = False): for k in keys: if ret[k] is None: if keep_empty: - ret[k] = "" + ret[k] = ( + "" + if self.__dataclass_fields__[k].default is None + else self.__dataclass_fields__[k].default + ) else: del ret[k] - return ret + ret2 = dict() + _rename_with_dash(ret, ret2) + return ret2 def new_stage(self, pos: int = None, **kwargs) -> StageDefinition: """Add a new stage in the optionally-specified `pos` position, and create it - based on keyword arguments. Passes existing :attr:`~Scenario.defaults` - unless a separate `defaults` dictionary is passed as one of the keyword arguments. + based on keyword arguments. Passes existing :attr:`~Scenario.defaults`. Parameters ---------- @@ -916,7 +971,11 @@ def new_stage(self, pos: int = None, **kwargs) -> StageDefinition: new stage created from the keyword arguments. It will have been added into the stage list in the proper position. """ - + if "defaults" in kwargs: + raise DeprecationWarning( + "Please do not pass defaults as a keyword, set the default values on the scenario instead. This will be an error in SANSMIC 1.1" + ) + # TODO: disable passing manual defaults in v1.1 defaults = kwargs.pop("defaults", self.defaults) stage = StageDefinition(defaults=defaults, **kwargs) if pos is None: @@ -948,6 +1007,31 @@ def new_simulation( return Simulator(self, prefix, verbosity, generate_tst_file, generate_out_file) + def squash_defaults(self, infer_values=False): + """Remove values which are the same as the scenario default values. + + If the `infer_values` parameter is active, then the first non-null value + found in the stages will be added to any existing default values first. + + Parameters + ---------- + infer_values : bool, optional + Populate missing :attr:`Scenario.defaults` values with the first + non-null value found in the stages, by default False + """ + if len(self.stages) == 0: + return + if infer_values: + for k in StageDefinition.valid_default_keys: + if self.defaults.get(k, None) is None: + for stage in self.stages: + self.defaults[k] = getattr(stage, k) + if self.defaults[k] is not None: + break + for stage in self.stages: + stage._relink_defaults(self) + stage.squash_defaults() + def _to_cscenario(self): """Create a C++ model object; in general, this should only be called internally.""" @@ -1033,7 +1117,7 @@ def _to_cscenario(self): for stage_num, stage in enumerate(self.stages): if stage_num == 0: stage.set_initial_conditions = True - cstage = stage._to_cstage(defaults=self.defaults, position=stage_num + 1) + cstage = stage._to_cstage(position=stage_num + 1) cscenario.add_stage(cstage) return cscenario diff --git a/tests/test_app.py b/tests/test_app.py index f9cf25b..0930adb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -192,6 +192,8 @@ def test_convert_app(self): scenario1 = sansmic.io.read_scenario(self.withdrawal_toml) scenario1.title = "" scenario1.comments = "" + scenario0.squash_defaults(False) + scenario1.squash_defaults(False) self.assertEqual(scenario0, scenario1) sansmic.app.convert( [self.withdrawal_dat, self.withdrawal_toml, "--full"], standalone_mode=False @@ -200,7 +202,7 @@ def test_convert_app(self): scenario2.title = "" scenario2.comments = "" self.maxDiff = None - self.assertDictEqual(scenario0.to_dict(), scenario2.to_dict()) + self.assertDictEqual(scenario0.to_dict(True), scenario2.to_dict(True)) with self.assertRaises( (click.FileError, click.ClickException, FileNotFoundError) diff --git a/tests/test_io.py b/tests/test_io.py index da8c072..e7dcb60 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -227,7 +227,7 @@ def test_read_scenario_ordinary_reverse_multistage(self): ) self.assertEqual(scenario.stages[1].brine_injection_depth, 4000 - 800.0) self.assertEqual(scenario.stages[1].brine_production_depth, 4000 - 100.0) - self.assertEqual(scenario.stages[1].brine_interface_depth, 0.0) + self.assertIsNone(scenario.stages[1].brine_interface_depth) self.assertEqual(scenario.stages[1].brine_injection_rate, 240000.0) self.assertEqual(scenario.stages[1].inner_tbg_inside_diam / 2, 4.925) self.assertEqual(scenario.stages[1].inner_tbg_outside_diam / 2, 5.375) @@ -252,7 +252,7 @@ def test_read_scenario_ordinary_reverse_multistage(self): ) self.assertEqual(scenario.stages[2].brine_injection_depth, 4000 - 800.0) self.assertEqual(scenario.stages[2].brine_production_depth, 4000 - 100.0) - self.assertEqual(scenario.stages[2].brine_interface_depth, 0.0) + self.assertIsNone(scenario.stages[2].brine_interface_depth) self.assertEqual(scenario.stages[2].brine_injection_rate, 240000.0) self.assertEqual(scenario.stages[2].inner_tbg_inside_diam / 2, 4.925) self.assertEqual(scenario.stages[2].inner_tbg_outside_diam / 2, 5.375) diff --git a/tests/test_model.py b/tests/test_model.py index 8a47a23..1dbe0df 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -74,7 +74,7 @@ def test_init_options(self): "save-frequency": "daily", } ) - self.assertEqual(stage.save_frequency, "daily") + self.assertIsNone(stage.save_frequency) def test_custom_setattr(self): stage = sansmic.model.StageDefinition()