diff --git a/.github/workflows/ci_pycopm_docker.yml b/.github/workflows/ci_pycopm_docker.yml index f2b177e..6cc6fc6 100644 --- a/.github/workflows/ci_pycopm_docker.yml +++ b/.github/workflows/ci_pycopm_docker.yml @@ -1,9 +1,7 @@ name: Docker on: - push: - branches: - - main + pull_request: jobs: run-pycopm-docker: diff --git a/.gitignore b/.gitignore index 625c101..e943ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -165,25 +165,18 @@ cython_debug/ # Python environment vpycopm/ -# DUNE -dune-common/ -dune-geometry/ -dune-grid/ -dune-istl/ - -# The OPM -opm-common/ -opm-grid/ -opm-simulators/ +# OPM +dune-* +opm-* # pycopm -cssr/ -developing/ -examples/configurations/drogon/drogon_coarser -test_outputs/ -playground/ -presentation/ -prototyping/ -project/ -studies/ +/cssr/ +/developing/ +/examples/configurations/drogon/drogon_coarser +/test_outputs/ +/playground/ +/presentation/ +/prototyping/ +/project/ +/studies/ .vscode/ diff --git a/README.md b/README.md index 40b9904..a9d9d62 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,13 @@ To install the _pycopm_ executable from the development version: pip install git+https://github.com/cssr-tools/pycopm.git ``` -If you are interested in a specific version (e.g., v2026.04) or in modifying the source code, then you can clone the repository and install the Python requirements in a virtual environment with the following commands: +If you are interested in modifying the source code, then you can clone the repository and install the Python requirements in a virtual environment with the following commands: ```bash # Clone the repo git clone https://github.com/cssr-tools/pycopm.git # Get inside the folder cd pycopm -# For a specific version (e.g., v2026.04), or skip this step (i.e., edge version) -git checkout v2026.04 # Create virtual environment (to specific Python, python3.13 -m venv vpycopm) python3 -m venv vpycopm # Activate virtual environment diff --git a/dev-requirements.txt b/dev-requirements.txt index e3002b3..30dedcf 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,8 +1,12 @@ -black<=26.5.1 -mypy<=2.3.1 -pylint<=4.0.7 -pytest-cov<=7.1.0 -pytest-xdist<=3.8.0 -ruff<=0.16.4 -sphinx<=9.1.0 -sphinx-rtd-theme<=3.1.0 +black +numpydoc +mypy +pydata_sphinx_theme +pylint +pytest-cov +pytest-xdist +ruff +sphinx +sphinx_copybutton +sphinx_design +sphinx-rtd-theme diff --git a/docs/Makefile b/docs/Makefile index 85bc206..3d6b173 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,25 +1,26 @@ -# Minimal makefile for Sphinx documentation -# +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = text +BUILDDIR = _build +APIDIR = text/api -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SOURCEDIR = text -BUILDDIR = _build +.PHONY: help clean api html linkcheck github -# Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help Makefile +clean: + rm -rf "$(BUILDDIR)" -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - sphinx-apidoc --private -e -f -o text ../src/pycopm - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) +api: + @mkdir -p "$(APIDIR)" + sphinx-apidoc --private -e -f -o "$(APIDIR)" ../src/pycopm -github: - @make html - cp -a _build/html/. . +html: api + @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +linkcheck: + @$(SPHINXBUILD) -M linkcheck "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +github: html + cp -a "$(BUILDDIR)/html/." . diff --git a/docs/_modules/index.html b/docs/_modules/index.html new file mode 100644 index 0000000..d784467 --- /dev/null +++ b/docs/_modules/index.html @@ -0,0 +1,590 @@ + + + + + + +
+ + +
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0912,R0914,R0915
+
+"""Command-line entry point and top-level workflow coordination for pycopm.
+
+pycopm supports two input workflows:
+
+* OPM ``.DATA`` decks can be coarsened, refined, transformed, or reduced to a
+ submodel.
+* TOML configurations generate coarsened Norne or Drogon cases and can
+ optionally run OPM Flow or ERT studies.
+
+This module parses and validates command-line arguments, selects the appropriate
+workflow, and coordinates its major processing steps. The numerical and
+file-generation details are implemented in the utility modules.
+"""
+
+import argparse
+import re
+import shlex
+import shutil
+import subprocess
+import time
+from pathlib import Path
+
+from pycopm.utils.coarsening import coarsen_and_write_properties, create_coarsening_map
+from pycopm.utils.files_writer import write_coarsened_model_files
+from pycopm.utils.generate_decks import create_deck
+from pycopm.utils.input_values import create_deck_config, load_toml_config
+from pycopm.utils.runs_executer import generate_postprocessing_plots, run_simulations
+from pycopm.utils.terminal import (
+ cli_correct_value,
+ cli_error_value,
+ cli_info_value,
+ pycopm_error,
+ pycopm_info,
+ pycopm_success,
+)
+
+
+
+[docs]
+def main(argv: list[str] | None = None) -> None:
+ """Run the deck-based or TOML-based pycopm workflow.
+
+ OPM ``.DATA`` decks can be coarsened, refined, transformed, or reduced to
+ a submodel. TOML configurations generate coarsened Norne or Drogon cases
+ and can optionally run OPM Flow or ERT studies.
+
+ Parameters
+ ----------
+ argv
+ Command-line arguments. If omitted, arguments are read from
+ ``sys.argv``.
+
+ Other Parameters
+ ----------------
+ -i, --input_deck_path
+ Input ``.DATA`` deck or TOML configuration file.
+ -o, --output_directory
+ Directory for generated decks, include files, and simulation results.
+ -f, --flow_command
+ Command or path used to run OPM Flow.
+ -m, --execution_mode
+ Deck-processing stages to run: ``prep``, ``deck``, ``dry``,
+ ``prep_deck``, ``deck_dry``, or ``all``.
+ -v, --vicinity_specification
+ Submodel selection based on region values, an xy polygon, or a
+ well-centred ``box``, ``diamond``, or ``diamondxy`` neighbourhood.
+ -c, --coarsening
+ Uniform coarsening factors in the x, y, and z directions.
+ -x, --x_coarsening
+ Cell-specific coarsening specification along the x axis.
+ -y, --y_coarsening
+ Cell-specific coarsening specification along the y axis.
+ -z, --z_coarsening
+ Cell-specific coarsening specification along the z axis.
+ -g, --refinement
+ Uniform numbers of additional cells along the x, y, and z axes.
+ -rx, --x_refinement
+ Number of additional cells for each original x interval.
+ -ry, --y_refinement
+ Number of additional cells for each original y interval.
+ -rz, --z_refinement
+ Number of additional cells for each original z interval.
+ -a, --active_cell_methods
+ Aggregation method for active-cell values: ``min``, ``max``, or
+ ``mode``.
+ -n, --discrete_aggregation_method
+ Aggregation method for discrete properties: ``min``, ``max``, or
+ ``mode``.
+ -s, --continuous_aggregation_method
+ Aggregation method for continuous properties: ``min``, ``max``,
+ ``mean``, or pore-volume-weighted mean (``pvmean``). If omitted,
+ property-specific physical aggregation is used.
+ -p, --pore_volume_correction
+ Pore-volume correction method. The available values are ``0`` through
+ ``4``; supported methods depend on the selected workflow.
+ -q, --correct_fluid_in_place
+ Set to ``1`` to adjust pore volume to match the initial oil and gas in
+ place of the input model.
+ -t, --transmissibility_coarsening_method
+ Transmissibility coarsening method: ``0``, ``1``, or ``2``.
+ -r, --completion_removal_level
+ Level of COMPDAT data removed after coarsening: ``0``, ``1``, or
+ ``2``.
+ -j, --jump_thresholds
+ Positive depth-jump thresholds used to prevent unwanted connections
+ between cells grouped during coarsening.
+ -w, --output_deck_name
+ Name of the generated OPM deck.
+ -l, --include_prefix
+ Prefix added to generated include filenames.
+ -e, --deck_encoding
+ Character encoding used to read the input deck: ``ISO-8859-1`` or
+ ``utf8``.
+ -ijk, --requested_ijk
+ One-based input-grid ``i,j,k`` indices to map to the modified grid.
+ -d, --grid_transformation
+ Coordinate transformation: ``translate [x,y,z]``, ``scale [x,y,z]``,
+ or ``rotatexy``, ``rotatexz``, or ``rotateyz`` followed by an angle
+ in degrees.
+ -explicit, --write_explicit_solution
+ Set to ``1`` to write initial solution properties explicitly instead
+ of retaining EQUIL initialization.
+ -dual, --dual_porosity_criterion
+ Static-property criterion used to separate matrix and fracture or
+ non-net cells during coarsening.
+ -precision, --significant_digits
+ Number of significant digits used when writing floating-point values.
+ Set to ``0`` to preserve machine precision.
+ """
+ start_time = time.monotonic()
+ cmdargs = _parse_arguments(argv)
+ _check_cmdargs(cmdargs)
+ output_folder = Path(cmdargs.output_directory).expanduser().resolve()
+ input_file = cmdargs.input_deck_path
+ output_folder.mkdir(parents=True, exist_ok=True)
+
+ # Process a DATA deck by coarsening, refining, extracting, or transforming it
+ if input_file.endswith(".DATA"):
+ dck = create_deck_config(cmdargs)
+ create_deck(dck, cmdargs)
+ return
+
+ # Process a TOML file by generating a coarsened Norne or Drogon project
+
+ # Load the TOML configuration and derive the reference-model settings
+ resource_directory = str(Path(__file__).resolve().parent.parent)
+ cfg = load_toml_config(
+ input_file,
+ str(output_folder),
+ resource_directory,
+ int(cmdargs.significant_digits),
+ )
+ cfg.flow_command = _check_flow(cmdargs.flow_command, cfg.flow_command, input_file)
+ pycopm_info(
+ f"generating the input files for {cli_info_value(cfg.model_name)}, "
+ "please wait..."
+ )
+
+ for folder in ["preprocessing", "parameters", "jobs", "observations"]:
+ (output_folder / folder).mkdir(parents=True, exist_ok=True)
+
+ # Build the coarse grid and map fine cells to coarse cells
+ coarsening_map = create_coarsening_map(cfg)
+
+ # Aggregate and write the properties for the coarse grid
+ number_tables = coarsen_and_write_properties(cfg, coarsening_map)
+
+ # Render the Flow, ERT, observation, parameter, and job files
+ write_coarsened_model_files(cfg, number_tables)
+
+ # Copy the model-specific INCLUDE files required by the generated deck
+ include_folder = "include" if cfg.model_name == "drogon" else "INCLUDE"
+ source_include = (
+ Path(cfg.resource_directory)
+ / "reference_simulation"
+ / cfg.model_name
+ / include_folder
+ )
+ destination_include = output_folder / "preprocessing" / include_folder
+ shutil.copytree(source_include, destination_include, dirs_exist_ok=True)
+ pycopm_success("input files required by ERT written to ", cfg.output_directory, [])
+ if cfg.execution_mode in ["single-run", "ert"]:
+
+ pycopm_info("running ERT, please wait...")
+ # Run OPM Flow or the selected ERT workflow
+ run_simulations(cfg)
+
+ # Generate the postprocessing plots after the simulations
+ generate_postprocessing_plots(cfg, time.monotonic() - start_time, number_tables)
+
+
+
+
+[docs]
+def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
+ """Parse supported command-line arguments.
+
+ Unknown arguments are left unprocessed for compatibility with external
+ launchers.
+
+ Parameters
+ ----------
+ argv
+ Command-line arguments. If omitted, arguments are read from ``sys.argv``.
+
+ Returns
+ -------
+ dict[str, str]
+ Arguments keyed by their destination names."""
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ description="Tailor a geological model and optionally run simulations "
+ "using OPM Flow. All options can be used with DATA decks, while only "
+ "-i, -o, -f, and -precision apply to TOML configuration files. See the "
+ "online documentation for examples and detailed option descriptions: "
+ "https://cssr-tools.github.io/pycopm/introduction.html#overview",
+ )
+ parser.add_argument(
+ "-i",
+ "--input_deck_path",
+ type=str.strip,
+ default="input.toml",
+ help="The base name of the TOML configuration file or the name of the deck",
+ )
+ parser.add_argument(
+ "-o",
+ "--output_directory",
+ type=str.strip,
+ default=".",
+ help="The base name of the output folder",
+ )
+ parser.add_argument(
+ "-f",
+ "--flow_command",
+ type=str.strip,
+ default="flow",
+ help="Set path to flow executable",
+ )
+ parser.add_argument(
+ "-m",
+ "--execution_mode",
+ type=str.strip,
+ choices=["prep", "deck", "dry", "prep_deck", "deck_dry", "all"],
+ default="prep_deck",
+ help="Parts of pycopm to run",
+ )
+ parser.add_argument(
+ "-v",
+ "--vicinity_specification",
+ type=str.strip,
+ default="",
+ help="The location to extract the sub model which can be assigned by "
+ "region values (e.g., 'fipnum 2,4'), by a polygon given the xy locations "
+ "in meters (e.g., 'xypolygon [0,0] [30,0] [30,30] [0,0]'), or by the name "
+ "of the well and three different options for the neighbourhood: box, "
+ "diamond, and diamondxy (e.g., 'welln box [-1,1] [-2,2] [0,3]')",
+ )
+ parser.add_argument(
+ "-c",
+ "--coarsening",
+ type=str.strip,
+ default="",
+ help="Level of coarsening in the x, y, and z dir",
+ )
+ parser.add_argument(
+ "-x",
+ "--x_coarsening",
+ type=str.strip,
+ default="",
+ help="Array of x coarsening",
+ )
+ parser.add_argument(
+ "-y",
+ "--y_coarsening",
+ type=str.strip,
+ default="",
+ help="Array of y coarsening",
+ )
+ parser.add_argument(
+ "-z",
+ "--z_coarsening",
+ type=str.strip,
+ default="",
+ help="Array of z coarsening",
+ )
+ parser.add_argument(
+ "-g",
+ "--refinement",
+ type=str.strip,
+ default="",
+ help="Level of grid refinement in the x, y, and z dir",
+ )
+ parser.add_argument(
+ "-rx",
+ "--x_refinement",
+ type=str.strip,
+ default="",
+ help="Array of x refinement",
+ )
+ parser.add_argument(
+ "-ry",
+ "--y_refinement",
+ type=str.strip,
+ default="",
+ help="Array of y refinement",
+ )
+ parser.add_argument(
+ "-rz",
+ "--z_refinement",
+ type=str.strip,
+ default="",
+ help="Array of z refinement",
+ )
+ parser.add_argument(
+ "-a",
+ "--active_cell_methods",
+ type=str.strip,
+ default="mode",
+ help="Select aggregation method (min, max, or mode) for the active "
+ "cells (for coarsening in the z direction separate by commas to "
+ "specify an approach per layer)",
+ )
+ parser.add_argument(
+ "-n",
+ "--discrete_aggregation_method",
+ type=str.strip,
+ default="mode",
+ help="Select aggregation method for the discrete variables (min, max, mode)",
+ )
+ parser.add_argument(
+ "-s",
+ "--continuous_aggregation_method",
+ type=str.strip,
+ default="",
+ help="Select aggregation method for the continuous variables (min, max, mean, "
+ "pvmean; by default these are property/direction dependent, e.g., harmonic "
+ "average for permeability",
+ )
+ parser.add_argument(
+ "-p",
+ "--pore_volume_correction",
+ type=str.strip,
+ choices=["0", "1", "2", "3", "4"],
+ default="0",
+ help="Select pore volume correction approach",
+ )
+ parser.add_argument(
+ "-q",
+ "--correct_fluid_in_place",
+ type=str.strip,
+ choices=["0", "1"],
+ default="0",
+ help="Adjust the pv to the initial FGIP and FOIP",
+ )
+ parser.add_argument(
+ "-t",
+ "--transmissibility_coarsening_method",
+ type=str.strip,
+ choices=["0", "1", "2"],
+ default="0",
+ help="Select coarsening method for transmissibilities",
+ )
+ parser.add_argument(
+ "-r",
+ "--completion_removal_level",
+ type=str.strip,
+ choices=["0", "1", "2"],
+ default="2",
+ help="Select COMPDAT entries to remove after coarsening",
+ )
+ parser.add_argument(
+ "-j",
+ "--jump_thresholds",
+ type=str.strip,
+ default="",
+ help="Parameter to avoid creation of neighbouring connections after coarsening",
+ )
+ parser.add_argument(
+ "-w",
+ "--output_deck_name",
+ type=str.strip,
+ default="",
+ help="Name of the generated deck",
+ )
+ parser.add_argument(
+ "-l",
+ "--include_prefix",
+ type=str.strip,
+ default="PYCOPM_",
+ help="Added text before each generated .INC",
+ )
+ parser.add_argument(
+ "-e",
+ "--deck_encoding",
+ type=str.strip,
+ choices=["ISO-8859-1", "utf8"],
+ default="ISO-8859-1",
+ help="Encoding to read the deck",
+ )
+ parser.add_argument(
+ "-ijk",
+ "--requested_ijk",
+ type=str.strip,
+ default="",
+ help="Returns the modified indices given as entry the 'i,j,k' indices",
+ )
+ parser.add_argument(
+ "-d",
+ "--grid_transformation",
+ type=str.strip,
+ default="",
+ help="Select transformation method (e.g, 'translate [10,-5,4]', "
+ "'scale [1,2,3]', or 'rotatexy 45')",
+ )
+ parser.add_argument(
+ "-explicit",
+ "--write_explicit_solution",
+ type=str.strip,
+ choices=["0", "1"],
+ default="0",
+ help="Set to 1 to explicitly write the cell values in the SOLUTION section",
+ )
+ parser.add_argument(
+ "-dual",
+ "--dual_porosity_criterion",
+ type=str.strip,
+ default="",
+ help="Set the criterium to differentiate net and non-net in coarsening using a static "
+ "variable. To remove the vertical transfer function (FT) between net and not-net cells, "
+ "add to the command ', vertical TF = 0', e.g., 'poro <= 0.1' (which includes vertical TF) "
+ "or 'poro <= 0.1, vertical TF = 0'",
+ )
+ parser.add_argument(
+ "-precision",
+ "--significant_digits",
+ type=str.strip,
+ choices=[str(value) for value in range(16)],
+ default="7",
+ help="Set the number of significant digits used when writing floating-point values, or 0 "
+ "to use machine precision",
+ )
+ return parser.parse_args(argv)
+
+
+
+
+[docs]
+def _check_cmdargs(cmdargs: argparse.Namespace) -> None:
+ """Validate command-line arguments and incompatible operations.
+
+ The checks cover input type, Flow availability, coarsening and refinement
+ syntax, aggregation methods, vicinity selections, transformations, and
+ options restricted to particular workflows.
+
+ Parameters
+ ----------
+ cmdargs
+ Parsed arguments returned by :func:`_parse_arguments`.
+
+ Raises
+ ------
+ SystemExit
+ If an argument is invalid or an incompatible combination is requested."""
+ input_file = cmdargs.input_deck_path
+ # Select the workflow from the input filename extension
+ if not input_file.endswith((".DATA", ".toml")):
+ pycopm_error(
+ f"invalid extension {cli_error_value(f'-i {input_file}')}, valid extensions "
+ f"are {cli_correct_value('.DATA')} or {cli_correct_value('.toml')}."
+ )
+ if not cmdargs.output_directory:
+ pycopm_error(
+ f"invalid value {cli_error_value('-o')}, the output folder cannot be empty."
+ )
+ # Only -i, -o, -f, and -precision apply to TOML configuration files
+ if input_file.endswith(".toml"):
+ data_options = {
+ "-m": ("execution_mode", "prep_deck"),
+ "-v": ("vicinity_specification", ""),
+ "-c": ("coarsening", ""),
+ "-x": ("x_coarsening", ""),
+ "-y": ("y_coarsening", ""),
+ "-z": ("z_coarsening", ""),
+ "-g": ("refinement", ""),
+ "-rx": ("x_refinement", ""),
+ "-ry": ("y_refinement", ""),
+ "-rz": ("z_refinement", ""),
+ "-a": ("active_cell_methods", "mode"),
+ "-n": ("discrete_aggregation_method", "mode"),
+ "-s": ("continuous_aggregation_method", ""),
+ "-p": ("pore_volume_correction", "0"),
+ "-q": ("correct_fluid_in_place", "0"),
+ "-t": ("transmissibility_coarsening_method", "0"),
+ "-r": ("completion_removal_level", "2"),
+ "-j": ("jump_thresholds", ""),
+ "-w": ("output_deck_name", ""),
+ "-l": ("include_prefix", "PYCOPM_"),
+ "-e": ("deck_encoding", "ISO-8859-1"),
+ "-ijk": ("requested_ijk", ""),
+ "-d": ("grid_transformation", ""),
+ "-explicit": ("write_explicit_solution", "0"),
+ "-dual": ("dual_porosity_criterion", ""),
+ }
+ invalid_options = [
+ option
+ for option, (name, default) in data_options.items()
+ if getattr(cmdargs, name) != default
+ ]
+ if invalid_options:
+ pycopm_error(
+ "invalid option for a TOML configuration file; only '-i', '-o', "
+ "'-f', and '-precision' can be used. Invalid options: "
+ f"{', '.join(invalid_options)}."
+ )
+ return
+ # Verify the complete Flow command, including any launcher and arguments
+ try:
+ flow_arguments = shlex.split(cmdargs.flow_command)
+ except ValueError:
+ flow_arguments = []
+ if not flow_arguments:
+ pycopm_error(
+ f"invalid OPM Flow command {cli_error_value(f'-f {cmdargs.flow_command}')}."
+ )
+ try:
+ flow_result = subprocess.run(
+ [*flow_arguments, "-h"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.STDOUT,
+ check=False,
+ )
+ except OSError:
+ flow_result = None
+ if flow_result is None or flow_result.returncode != 0:
+ pycopm_error(
+ f"the OPM Flow executable '-f {cmdargs.flow_command}' "
+ "is not available or not working."
+ )
+ coarsening = cmdargs.coarsening
+ x_coarsening = cmdargs.x_coarsening
+ y_coarsening = cmdargs.y_coarsening
+ z_coarsening = cmdargs.z_coarsening
+ refinement = cmdargs.refinement
+ x_refinement = cmdargs.x_refinement
+ y_refinement = cmdargs.y_refinement
+ z_refinement = cmdargs.z_refinement
+ vicinity = cmdargs.vicinity_specification
+ transformation = cmdargs.grid_transformation
+ directional_coarsening = any([x_coarsening, y_coarsening, z_coarsening])
+ directional_refinement = any([x_refinement, y_refinement, z_refinement])
+ has_coarsening = bool(coarsening or directional_coarsening)
+ has_refinement = bool(refinement or directional_refinement)
+ # General and directional coarsening options are mutually exclusive
+ if coarsening and directional_coarsening:
+ pycopm_error(
+ "invalid combination, either set '-c' or the '-x', '-y', and '-z' flags."
+ )
+ # General and directional refinement options are mutually exclusive
+ if refinement and directional_refinement:
+ pycopm_error(
+ "invalid combination, either set '-g' or the '-rx', '-ry', and "
+ "'-rz' flags."
+ )
+ # Coarsening and refinement are mutually exclusive
+ if has_coarsening and has_refinement:
+ pycopm_error(
+ "invalid combination, either set coarsening or refinement options."
+ )
+ # Vicinity extraction, transformation, and refinement are mutually exclusive
+ if vicinity and transformation:
+ pycopm_error("invalid combination, either set '-v' or '-d'.")
+ if vicinity and has_refinement:
+ pycopm_error("invalid combination, either set '-v' or refinement options.")
+ if transformation and has_refinement:
+ pycopm_error("invalid combination, either set '-d' or refinement options.")
+ # Validate uniform coarsening and refinement levels
+ level_pattern = re.compile(r"\d+,\d+,\d+")
+ if coarsening and not level_pattern.fullmatch(coarsening):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-c {coarsening}')}, expected three non-negative "
+ f"integers separated by commas, {cli_correct_value('e.g., -c 2,2,1')}."
+ )
+ if refinement and not level_pattern.fullmatch(refinement):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-g {refinement}')}, expected three non-negative "
+ f"integers separated by commas, {cli_correct_value('e.g., -g 2,2,1')}."
+ )
+ # Validate directional coarsening arrays, indices, and ranges
+ coarsening_array_pattern = re.compile(r"\d+(?:,\d+)*")
+ coarsening_group_pattern = re.compile(
+ r"[1-9]\d*(?::[1-9]\d*)?(?:,[1-9]\d*(?::[1-9]\d*)?)*"
+ )
+ for option, value in [
+ ("-x", x_coarsening),
+ ("-y", y_coarsening),
+ ("-z", z_coarsening),
+ ]:
+ if value and not (
+ coarsening_array_pattern.fullmatch(value)
+ or coarsening_group_pattern.fullmatch(value)
+ ):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'{option} {value}')}, expected a non-negative "
+ "coarsening array or positive indices and ranges separated by "
+ "commas."
+ )
+ if ":" in value:
+ for entry in value.split(","):
+ if ":" not in entry:
+ continue
+ start, end = (int(index) for index in entry.split(":"))
+ if start > end:
+ pycopm_error(
+ f"invalid range '{entry}' in '{option} {value}', "
+ "the end must not be smaller than the start."
+ )
+ # Validate directional refinement arrays
+ refinement_array_pattern = re.compile(r"\d+(?:,\d+)*")
+ for option, value in [
+ ("-rx", x_refinement),
+ ("-ry", y_refinement),
+ ("-rz", z_refinement),
+ ]:
+ if value and not refinement_array_pattern.fullmatch(value):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'{option} {value}')}, expected non-negative "
+ "integers separated by commas."
+ )
+ # Validate aggregation methods
+ aggregation_options = [
+ ("-a", "active_cell_methods", ["min", "max", "mode"]),
+ ("-n", "discrete_aggregation_method", ["min", "max", "mode"]),
+ (
+ "-s",
+ "continuous_aggregation_method",
+ ["min", "max", "mean", "pvmean"],
+ ),
+ ]
+ z_groups = z_coarsening.split(",") if ":" in z_coarsening else []
+ for option, name, valid_methods in aggregation_options:
+ value = getattr(cmdargs, name).strip()
+ methods = value.split(",") if value else []
+ if any(method not in valid_methods for method in methods):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'{option} {value}')}, valid values are "
+ f"{cli_correct_value(', '.join(valid_methods))}."
+ )
+ if len(methods) > 1 and not z_groups:
+ pycopm_error(
+ f"invalid value {cli_error_value(f'{option} {value}')}, multiple aggregation "
+ "methods require range coarsening with '-z'."
+ )
+ if len(methods) > 1 and len(methods) != len(z_groups):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'{option} {value}')}, expected one aggregation "
+ "method for each index or range provided with '-z'."
+ )
+ # Options controlling property aggregation require coarsening
+ if not has_coarsening:
+ if cmdargs.active_cell_methods != "mode":
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-a')} can only be used with coarsening."
+ )
+ if cmdargs.discrete_aggregation_method != "mode":
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-n')} can only be used with coarsening."
+ )
+ if cmdargs.continuous_aggregation_method:
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-s')} can only be used with coarsening."
+ )
+ if cmdargs.transmissibility_coarsening_method != "0":
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-t')} can only be used with coarsening."
+ )
+ if cmdargs.jump_thresholds:
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-j')} can only be used with coarsening."
+ )
+ if cmdargs.dual_porosity_criterion:
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-dual')} can only be used with coarsening."
+ )
+ # Fluid-in-place correction is not supported for extracted submodels
+ if vicinity and cmdargs.correct_fluid_in_place == "1":
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-q')} cannot be used "
+ "with {cli_error_value('-v')}."
+ )
+ # Validate pore-volume correction combinations
+ pore_volume_correction = cmdargs.pore_volume_correction
+ if pore_volume_correction == "1" and not (has_coarsening or vicinity):
+ pycopm_error(
+ f"invalid combination, {cli_error_value('-p 1')} requires coarsening or "
+ f"{cli_correct_value('-v')}."
+ )
+ if pore_volume_correction in ["2", "3", "4"] and not vicinity:
+ pycopm_error(
+ f"invalid combination, {cli_error_value(f'-p {pore_volume_correction}')} can only be "
+ "used with '-v'."
+ )
+ # Validate the jump thresholds
+ jump_thresholds = cmdargs.jump_thresholds
+ if jump_thresholds:
+ try:
+ jump_values = [float(value.strip()) for value in jump_thresholds.split(",")]
+ except ValueError:
+ jump_values = []
+ if not jump_values or any(value <= 0 for value in jump_values):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-j {jump_thresholds}')}, expected positive "
+ "numbers separated by commas."
+ )
+ # Validate requested input-model indices
+ requested_ijk = cmdargs.requested_ijk
+ if requested_ijk and not re.fullmatch(
+ r"[1-9]\d*\s*,\s*[1-9]\d*\s*,\s*[1-9]\d*",
+ requested_ijk,
+ ):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-ijk {requested_ijk}')}, expected three positive "
+ f"indices separated by commas, {cli_correct_value('e.g., -ijk 1,2,3')}."
+ )
+ # Validate coordinate transformations
+ number = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
+ vector_transformation = re.fullmatch(
+ rf"(translate|scale)\s+\[\s*{number}\s*,\s*{number}\s*,\s*" rf"{number}\s*\]",
+ transformation,
+ )
+ rotation_transformation = re.fullmatch(
+ rf"(rotatexy|rotatexz|rotateyz)\s+{number}",
+ transformation,
+ )
+ if transformation and not (vector_transformation or rotation_transformation):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-d {transformation}')}, expected "
+ "'translate [x,y,z]', 'scale [x,y,z]', or 'rotatexy', 'rotatexz', "
+ "or 'rotateyz' followed by an angle."
+ )
+ if vector_transformation and vector_transformation.group(1) == "scale":
+ coordinates = re.findall(number, transformation)
+ if any(float(value) == 0 for value in coordinates):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-d {transformation}')}, scale values cannot be "
+ "zero."
+ )
+ # Validate vicinity extraction specifications
+ region_vicinity = re.fullmatch(
+ r"[A-Za-z][A-Za-z0-9_]*\s+[1-9]\d*(?:\s*,\s*[1-9]\d*)*",
+ vicinity,
+ )
+ polygon_point = rf"\[\s*{number}\s*,\s*{number}\s*\]"
+ polygon_vicinity = re.fullmatch(
+ rf"xypolygon(?:\s+{polygon_point}){{4,}}",
+ vicinity,
+ )
+ box_vicinity = re.fullmatch(
+ r"\S+\s+box(?:\s+\[\s*-?\d+\s*,\s*-?\d+\s*\]){3}",
+ vicinity,
+ )
+ diamond_vicinity = re.fullmatch(
+ r"\S+\s+(?:diamond|diamondxy)\s+\d+",
+ vicinity,
+ )
+ if vicinity and not (
+ region_vicinity or polygon_vicinity or box_vicinity or diamond_vicinity
+ ):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-v {vicinity}')}, expected a region selection, an "
+ "'xypolygon' specification, or a well followed by 'box', "
+ "'diamond', or 'diamondxy'."
+ )
+ if polygon_vicinity:
+ polygon_points = re.findall(polygon_point, vicinity)
+ first_point = re.findall(number, polygon_points[0])
+ last_point = re.findall(number, polygon_points[-1])
+ if first_point != last_point:
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-v {vicinity}')}, the first and last "
+ "xypolygon points must be equal."
+ )
+ if box_vicinity:
+ intervals = re.findall(
+ r"\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]",
+ vicinity,
+ )
+ if any(int(start) > int(end) for start, end in intervals):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-v {vicinity}')}, the end of each box interval "
+ "must not be smaller than its start."
+ )
+ # Validate the dual-porosity criterion
+ dual_porosity_criterion = cmdargs.dual_porosity_criterion
+ dual_criterion_pattern = re.compile(
+ rf"[A-Za-z][A-Za-z0-9_]*\s*(?:<=|>=|==|!=|<|>)\s*{number}"
+ r"(?:\s*,\s*vertical\s+TF\s*=\s*0)?",
+ re.IGNORECASE,
+ )
+ if dual_porosity_criterion and not dual_criterion_pattern.fullmatch(
+ dual_porosity_criterion
+ ):
+ pycopm_error(
+ f"invalid value {cli_error_value(f'-dual {dual_porosity_criterion}')}, expected a "
+ "static property criterion such as 'poro <= 0.1', optionally "
+ "followed by ', vertical TF = 0'."
+ )
+
+
+
+
+[docs]
+def _check_flow(flow_cmdargs: str, flow_toml: str, input_file: str) -> str:
+ """Select an available OPM Flow command for a TOML workflow.
+
+ Parameters
+ ----------
+ flow_cmdargs
+ Flow command supplied through the command line.
+ flow_toml
+ Flow command read from the TOML configuration.
+ input_file
+ TOML filename used in validation messages.
+
+ Returns
+ -------
+ str
+ The selected Flow command.
+
+ Raises
+ ------
+ SystemExit
+ If neither command identifies a working Flow executable."""
+ flowpth = str(
+ next((value for value in shlex.split(flow_toml) if "flow" in value), False)
+ )
+ if not flowpth:
+ pycopm_error(
+ f"flow is not included in the configuration file {cli_error_value(input_file)}. "
+ "see the pycopm documentation."
+ )
+
+ toml_command = shlex.split(flowpth) + ["-h"]
+ flag_command = shlex.split(flow_cmdargs) + ["-h"]
+
+ def flow_exists(command: list[str]) -> bool:
+ try:
+ return (
+ subprocess.run(
+ command,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.STDOUT,
+ check=False,
+ ).returncode
+ == 0
+ )
+ except OSError:
+ return False
+
+ toml_ok = flow_exists(toml_command)
+ flag_ok = flow_exists(flag_command)
+
+ if not (toml_ok or flag_ok):
+ pycopm_error(
+ f"the OPM Flow executable '{flowpth}' is not found; "
+ "try to install it following the pycopm documentation. If it was "
+ "built from source, then either add the folder location to your path, "
+ "or write the path to flow in the TOML configuration file "
+ "(e.g., flow = '/home/pycopm/build/opm-simulators/bin/flow'), "
+ "or using the command flag -f or --flow."
+ )
+ if toml_ok:
+ flow_command = flow_toml
+ else:
+ command_parts = shlex.split(flow_cmdargs)
+ for index, value in enumerate(command_parts):
+ if "flow" in value:
+ command_parts[index] = flow_cmdargs
+ break
+ flow_command = " ".join(command_parts)
+ return flow_command
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0902,R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,E1102
+
+"""Coarsen corner-point grids and aggregate reservoir properties.
+
+The module supports deck-based coarsening and the TOML workflows used to
+generate reduced Norne and Drogon models."""
+
+import argparse
+import csv
+import re
+import sys
+from contextlib import nullcontext
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+from alive_progress import alive_bar
+from numpy.typing import NDArray
+from opm.io.ecl import EclFile as OpmFile
+from opm.io.ecl import EGrid as OpmGrid
+from opm.io.ecl import ERst as OpmRestart
+
+from pycopm.config.config import ConfigViaDeck, ConfigViaTOML
+from pycopm.utils.files_writer import (
+ _render_template,
+ format_opm_compact_values,
+ round_like_e,
+ write_compact_property_file,
+ write_grid,
+ write_include,
+ write_property,
+ write_property_inc,
+ write_reference_to_coarse_map,
+)
+from pycopm.utils.input_values import parse_axis_modifications
+from pycopm.utils.terminal import pycopm_error, pycopm_info
+
+
+
+[docs]
+@dataclass(slots=True)
+class CoarseningMaps:
+ """Store mappings and intermediate values used during coarsening."""
+
+ #: Axis array marking boundaries removed by coarsening in the x direction.
+ #: Values greater than one identify intervals merged with the preceding
+ #: interval.
+ x: NDArray
+
+ #: Axis array marking boundaries removed by coarsening in the y direction.
+ #: Values greater than one identify intervals merged with the preceding
+ #: interval.
+ y: NDArray
+
+ #: Axis array marking boundaries removed by coarsening in the z direction.
+ #: Values greater than one identify intervals merged with the preceding
+ #: interval.
+ z: NDArray
+
+ #: One-based coarse-cell identifier for each original cell, flattened in
+ #: ``(z, y, x)`` order.
+ cell_groups: NDArray
+
+ #: Concatenated names of the coarsened axes, for example ``"xz"``.
+ coarsened_axes: str
+
+ #: Per-cell mask separating matrix cells (one) from fracture or non-net
+ #: cells (zero) in dual-porosity models.
+ matrix_mask: NDArray
+
+ #: Whether vertical matrix-fracture transfer connections are retained.
+ vertical_transfer_enabled: bool
+
+ #: Coarse-cell identifier for each reference-grid cell, populated while
+ #: properties are coarsened.
+ reference_to_coarse: list[int] = field(default_factory=list)
+
+ #: NNC include-file content accumulated while mapping non-neighbouring and
+ #: matrix-fracture connections.
+ nnc_text: str = "NNC\n"
+
+ #: Horizontal x-direction transmissibilities for the matrix or
+ #: single-porosity coarse grid.
+ coarse_tranx: NDArray = field(default_factory=lambda: np.array([]))
+
+ #: Horizontal y-direction transmissibilities for the matrix or
+ #: single-porosity coarse grid.
+ coarse_trany: NDArray = field(default_factory=lambda: np.array([]))
+
+ #: Horizontal x-direction transmissibilities for the fracture continuum of
+ #: a dual-porosity grid.
+ dual_tranx: NDArray = field(default_factory=lambda: np.array([]))
+
+ #: Horizontal y-direction transmissibilities for the fracture continuum of
+ #: a dual-porosity grid.
+ dual_trany: NDArray = field(default_factory=lambda: np.array([]))
+
+ #: Default property values inserted into separator rows of the extended
+ #: dual-porosity grid.
+ dual_defaults: dict[str, float] = field(default_factory=dict)
+
+
+
+
+[docs]
+def create_coarsening_maps(
+ dck: ConfigViaDeck, cmdargs: argparse.Namespace
+) -> CoarseningMaps:
+ """Create axis mappings and assign original cells to coarse cells.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose output dimensions are updated.
+ cmdargs
+ Command arguments containing ``coarsening``, ``x_coarsening``,
+ ``y_coarsening``, and ``z_coarsening``.
+
+ Returns
+ -------
+ CoarseningMaps
+ Axis mappings, cell groups, and dual-porosity masks."""
+ cijk, refs = parse_axis_modifications(
+ cmdargs.coarsening,
+ [
+ cmdargs.x_coarsening,
+ cmdargs.y_coarsening,
+ cmdargs.z_coarsening,
+ ],
+ )
+ matrix_mask = np.ones(dck.original_porv.size)
+ vertical_transfer_enabled = True
+ if dck.dual_porosity_criterion:
+ dual_criterion = str(dck.dual_porosity_criterion)
+ criterion_parts = dual_criterion.split()
+ vertical_transfer_enabled = "vertical TF = 0" not in dual_criterion
+ property_name = criterion_parts[0].upper()
+ comparison_operator = criterion_parts[1]
+ comparison_value = float(criterion_parts[2].rstrip(","))
+ property_values = np.asarray(dck.init_file[property_name])
+ active_cells = dck.original_porv > 0
+ if comparison_operator == "==":
+ matrix_mask[active_cells] = property_values != comparison_value
+ elif comparison_operator == ">=":
+ matrix_mask[active_cells] = property_values < comparison_value
+ elif comparison_operator == "<=":
+ matrix_mask[active_cells] = property_values > comparison_value
+ elif comparison_operator == "<":
+ matrix_mask[active_cells] = property_values >= comparison_value
+ elif comparison_operator == ">":
+ matrix_mask[active_cells] = property_values <= comparison_value
+ elif comparison_operator == "!=":
+ matrix_mask[active_cells] = property_values == comparison_value
+ else:
+ pycopm_error(f"unknown criterion for non-net cells: {dual_criterion}")
+ directions = ("x", "y", "z")
+ original_sizes = (
+ dck.original_nx,
+ dck.original_ny,
+ dck.original_nz,
+ )
+ if len(cijk) > 2:
+ coarsened_axes = "".join(
+ direction
+ for direction, coarsening_factor in zip(directions, cijk)
+ if coarsening_factor > 1
+ )
+ else:
+ coarsened_axes = "".join(
+ direction
+ for direction_index, direction in enumerate(directions)
+ if len(refs[direction_index]) > 0
+ )
+ coarsenings = []
+ for direction_index, original_size in enumerate(original_sizes):
+ coarsening_values = np.zeros(original_size + 1, dtype=int)
+ if len(cijk) > 2:
+ coarsening_values.fill(2)
+ coarsening_values[: original_size : cijk[direction_index]] = 0
+ coarsening_values[-1] = 0
+ elif len(refs[direction_index]) > 0:
+ configured_values = np.asarray(
+ refs[direction_index],
+ dtype=int,
+ )
+ coarsening_values[: configured_values.size] = configured_values
+ coarsenings.append(coarsening_values)
+ coarse_coordinates = []
+ for coarsening_values, original_size in zip(
+ coarsenings,
+ original_sizes,
+ ):
+ coarse_coordinates.append(
+ np.concatenate(
+ (
+ np.zeros(1, dtype=np.intp),
+ np.cumsum(
+ coarsening_values[1:original_size] <= 1,
+ dtype=np.intp,
+ ),
+ )
+ )
+ )
+ coarse_i = coarse_coordinates[0]
+ coarse_j = coarse_coordinates[1]
+ coarse_k = coarse_coordinates[2]
+ coarse_nx = int(coarse_i[-1]) + 1
+ coarse_ny = int(coarse_j[-1]) + 1
+ cell_groups = (
+ coarse_i[None, None, :]
+ + coarse_j[None, :, None] * coarse_nx
+ + coarse_k[:, None, None] * coarse_nx * coarse_ny
+ + 1
+ ).reshape(-1)
+ for direction, coarsening_values, original_size in zip(
+ directions,
+ coarsenings,
+ original_sizes,
+ ):
+ setattr(
+ dck,
+ f"output_n{direction}",
+ original_size - int(np.count_nonzero(coarsening_values == 2)),
+ )
+ return CoarseningMaps(
+ x=coarsenings[0],
+ y=coarsenings[1],
+ z=coarsenings[2],
+ cell_groups=cell_groups,
+ coarsened_axes=coarsened_axes,
+ matrix_mask=matrix_mask,
+ vertical_transfer_enabled=vertical_transfer_enabled,
+ )
+
+
+
+
+[docs]
+def _grouped_sum(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the sum of values for each one-based group."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ valid_values = ~np.isnan(numeric_values)
+ return np.bincount(
+ group_indices[valid_values] - 1,
+ weights=numeric_values[valid_values],
+ minlength=number_groups,
+ )
+
+
+
+
+[docs]
+def _grouped_count(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the number of non-NaN values for each one-based group."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ valid_values = ~np.isnan(numeric_values)
+ return np.bincount(
+ group_indices[valid_values] - 1,
+ minlength=number_groups,
+ )
+
+
+
+
+[docs]
+def _grouped_min(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the minimum value for each one-based group, ignoring NaNs."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ result = np.full(number_groups, np.inf)
+ valid_values = ~np.isnan(numeric_values)
+ np.minimum.at(
+ result,
+ group_indices[valid_values] - 1,
+ numeric_values[valid_values],
+ )
+ result[np.isinf(result)] = np.nan
+ return result
+
+
+
+
+[docs]
+def _grouped_max(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the maximum value for each one-based group, ignoring NaNs."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ result = np.full(number_groups, -np.inf)
+ valid_values = ~np.isnan(numeric_values)
+ np.maximum.at(
+ result,
+ group_indices[valid_values] - 1,
+ numeric_values[valid_values],
+ )
+ result[np.isneginf(result)] = np.nan
+ return result
+
+
+
+
+[docs]
+def _grouped_mean(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the mean value for each one-based group, ignoring NaNs."""
+ sums = _grouped_sum(values, groups, size)
+ counts = _grouped_count(values, groups, size)
+ return np.divide(
+ sums,
+ counts,
+ out=np.full(sums.shape, np.nan),
+ where=counts > 0,
+ )
+
+
+
+
+[docs]
+def _grouped_first(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the first non-NaN value for each one-based group."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ source_indices = np.arange(numeric_values.size)
+ first_indices = np.full(number_groups, numeric_values.size, dtype=int)
+ valid_values = ~np.isnan(numeric_values)
+ np.minimum.at(
+ first_indices,
+ group_indices[valid_values] - 1,
+ source_indices[valid_values],
+ )
+ result = np.full(number_groups, np.nan)
+ valid_groups = first_indices < numeric_values.size
+ result[valid_groups] = numeric_values[first_indices[valid_groups]]
+ return result
+
+
+
+
+[docs]
+def _grouped_last(
+ values: NDArray,
+ groups: NDArray,
+ size: int | None = None,
+) -> NDArray:
+ """Return the last non-NaN value for each one-based group."""
+ numeric_values = np.asarray(values, dtype=float)
+ group_indices = np.asarray(groups, dtype=int)
+ number_groups = int(group_indices.max()) if size is None else size
+ source_indices = np.arange(numeric_values.size)
+ last_indices = np.full(number_groups, -1, dtype=int)
+ valid_values = ~np.isnan(numeric_values)
+ np.maximum.at(
+ last_indices,
+ group_indices[valid_values] - 1,
+ source_indices[valid_values],
+ )
+ result = np.full(number_groups, np.nan)
+ valid_groups = last_indices >= 0
+ result[valid_groups] = numeric_values[last_indices[valid_groups]]
+ return result
+
+
+
+
+[docs]
+def _grouped_mode(
+ values: NDArray,
+ group_codes: NDArray,
+ number_groups: int,
+) -> NDArray:
+ """Return the smallest mode for each zero-based group, ignoring NaNs."""
+ values = np.asarray(values, dtype=np.float64)
+ group_codes = np.asarray(group_codes)
+ valid = ~np.isnan(values)
+ if not np.any(valid):
+ return np.full(number_groups, np.nan, dtype=np.float64)
+ valid_groups = group_codes[valid]
+ valid_values = values[valid]
+ order = np.lexsort((valid_values, valid_groups))
+ sorted_groups = valid_groups[order]
+ sorted_values = valid_values[order]
+ pair_start = np.empty(sorted_values.size, dtype=bool)
+ pair_start[0] = True
+ pair_start[1:] = (sorted_groups[1:] != sorted_groups[:-1]) | (
+ sorted_values[1:] != sorted_values[:-1]
+ )
+ pair_indices = np.flatnonzero(pair_start)
+ pair_groups = sorted_groups[pair_indices]
+ pair_values = sorted_values[pair_indices]
+ pair_counts = np.diff(np.append(pair_indices, sorted_values.size))
+ best_order = np.lexsort(
+ (
+ pair_values,
+ -pair_counts,
+ pair_groups,
+ )
+ )
+ candidate_groups = pair_groups[best_order]
+ candidate_values = pair_values[best_order]
+ first_candidate = np.empty(candidate_groups.size, dtype=bool)
+ first_candidate[0] = True
+ first_candidate[1:] = candidate_groups[1:] != candidate_groups[:-1]
+ result = np.full(number_groups, np.nan, dtype=np.float64)
+ result[candidate_groups[first_candidate]] = candidate_values[first_candidate]
+ return result
+
+
+
+
+[docs]
+def coarsen_properties(
+ dck: ConfigViaDeck,
+ coarsening: CoarseningMaps,
+ modified_deck: list[str],
+ wellcind: list[int],
+) -> tuple[NDArray, NDArray, NDArray, list[str]]:
+ """Aggregate reservoir properties onto the coarsened grid.
+
+ Continuous properties use their configured or property-specific aggregation;
+ discrete properties use ``min``, ``max``, or ``mode``. The function writes
+ property include files and updates output pore volume and active cells.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration and source INIT or restart properties.
+ coarsening
+ Cell groups and masks created by :func:`create_coarsening_maps`.
+ modified_deck
+ Deck lines updated with generated property includes.
+ wellcind
+ Coarse-cell indices containing well completions.
+
+ Returns
+ -------
+ cluster_minimum, cluster_maximum, removal_mask, generated_files
+ Activity summaries, the mask used to remove depth-jump cells,
+ and the generated include file names."""
+ generated_files = []
+ actnum = np.zeros(dck.original_cell_count, dtype=int)
+ top_depths = np.full(dck.original_cell_count, np.nan)
+ base_depths = np.full(dck.original_cell_count, np.nan)
+ cell_heights = np.full(dck.original_cell_count, np.nan)
+ top_corner_indices = (0, 1, 2, 3)
+ bottom_corner_indices = (4, 5, 6, 7)
+ actnum = (dck.original_porv > 0).astype(int)
+ d_x = np.full(dck.original_cell_count, np.nan)
+ d_y = np.full(dck.original_cell_count, np.nan)
+ d_z = np.full(dck.original_cell_count, np.nan)
+ d_ax = np.full(dck.original_cell_count, np.nan)
+ d_ay = np.full(dck.original_cell_count, np.nan)
+ d_az = np.full(dck.original_cell_count, np.nan)
+ permx, permy, permz = np.array([]), np.array([]), np.array([])
+
+ cell_volumes = np.asarray(dck.grid_model.cellvolumes())
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(dck.original_cell_count, bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ with bar_ctx as bar_animation:
+ for coarse_k in range(dck.original_nz):
+ for coarse_j in range(dck.original_ny):
+ for coarse_i in range(dck.original_nx):
+ if show_progress:
+ bar_animation()
+ coarsening.reference_to_coarse.append(
+ dck.original_to_output_i[coarse_i + 1]
+ + (dck.original_to_output_j[coarse_j + 1] - 1) * dck.output_nx
+ + (dck.original_to_output_k[coarse_k + 1] - 1)
+ * dck.output_nx
+ * dck.output_ny
+ )
+ coarse_index = (
+ coarse_i
+ + coarse_j * dck.original_nx
+ + coarse_k * dck.original_nx * dck.original_ny
+ )
+ cell_coordinates = dck.grid_model.xyz_from_ijk(
+ coarse_i, coarse_j, coarse_k
+ )
+ x_length_0 = 0.0
+ x_length_1 = 0.0
+ y_length_0 = 0.0
+ y_length_1 = 0.0
+ z_length = 0.0
+ for corner, row_offset, column_offset in zip(
+ range(4), (0, 0, 1, 1), (0, 1, 0, 1)
+ ):
+ x_length_0 += (
+ abs(
+ cell_coordinates[0][1 + 2 * corner]
+ - cell_coordinates[0][2 * corner]
+ )
+ / 4.0
+ )
+ x_length_1 += (
+ abs(
+ cell_coordinates[1][1 + 2 * corner]
+ - cell_coordinates[1][2 * corner]
+ )
+ / 4.0
+ )
+ y_length_0 += (
+ abs(
+ cell_coordinates[0][column_offset + row_offset * 4 + 2]
+ - cell_coordinates[0][column_offset + row_offset * 4]
+ )
+ / 4.0
+ )
+ y_length_1 += (
+ abs(
+ cell_coordinates[1][column_offset + row_offset * 4 + 2]
+ - cell_coordinates[1][column_offset + row_offset * 4]
+ )
+ / 4.0
+ )
+ z_length += (
+ abs(
+ cell_coordinates[2][corner + 4]
+ - cell_coordinates[2][corner]
+ )
+ / 4.0
+ )
+ d_x[coarse_index] = np.hypot(x_length_0, x_length_1)
+ d_y[coarse_index] = np.hypot(y_length_0, y_length_1)
+ d_z[coarse_index] = z_length
+ top_depths[coarse_index] = min(
+ cell_coordinates[2][corner_index]
+ for corner_index in top_corner_indices
+ )
+ base_depths[coarse_index] = max(
+ cell_coordinates[2][corner_index]
+ for corner_index in top_corner_indices
+ )
+ bottom_depth = max(
+ cell_coordinates[2][corner_index]
+ for corner_index in bottom_corner_indices
+ )
+ cell_heights[coarse_index] = bottom_depth - top_depths[coarse_index]
+
+ cluster_ids = np.asarray(coarsening.cell_groups, dtype=int)
+ actnum_values = np.asarray(actnum)
+ cluster_maximum = _grouped_max(actnum_values, cluster_ids)
+ cluster_minimum_all = _grouped_min(actnum_values, cluster_ids)
+ group_codes = np.asarray(coarsening.cell_groups, dtype=np.intp) - 1
+ number_groups = int(group_codes.max()) + 1
+ cluster_mode = _grouped_mode(
+ actnum_values,
+ group_codes,
+ number_groups,
+ )
+ cluster_frequency = _grouped_sum(actnum_values, cluster_ids)
+ mean_cell_height = _grouped_mean(cell_heights, cluster_ids)
+ d_ax[dck.original_active_cell_mask] = d_x[dck.original_active_cell_mask]
+ d_ay[dck.original_active_cell_mask] = d_y[dck.original_active_cell_mask]
+ d_az[dck.original_active_cell_mask] = d_z[dck.original_active_cell_mask]
+
+ x_tot = _grouped_sum(d_x, cluster_ids)
+ y_tot = _grouped_sum(d_y, cluster_ids)
+ z_tot = _grouped_sum(d_z, cluster_ids)
+ za_tot = _grouped_sum(d_az, cluster_ids)
+ z_a = _grouped_sum(d_az, cluster_ids)
+ if dck.dual_porosity_criterion:
+ za_tot = _grouped_sum(d_az * (coarsening.matrix_mask == 1), cluster_ids)
+ za_tot_dual = _grouped_sum(d_az * (coarsening.matrix_mask == 0), cluster_ids)
+
+ total_volume = _grouped_sum(cell_volumes, cluster_ids)
+ if len(dck.active_cell_methods) == 1:
+ if dck.active_cell_methods[0] == "min":
+ cluster_minimum = cluster_minimum_all.copy()
+ elif dck.active_cell_methods[0] == "mode":
+ cluster_minimum = cluster_mode.copy()
+ else:
+ cluster_minimum = cluster_maximum.copy()
+ selected_actnum = cluster_minimum.copy()
+ else:
+ cluster_minimum = cluster_frequency.copy()
+ selected_actnum = cluster_frequency.copy()
+ cells_per_coarse_layer = dck.output_nx * dck.output_ny
+ for layer_index, aggregation in enumerate(dck.active_cell_methods):
+ layer_start = layer_index * cells_per_coarse_layer
+ layer_end = min(
+ layer_start + cells_per_coarse_layer,
+ cluster_frequency.size,
+ )
+ if aggregation == "min":
+ layer_values = cluster_minimum_all[layer_start:layer_end]
+ elif aggregation == "mode":
+ layer_values = cluster_mode[layer_start:layer_end]
+ else:
+ layer_values = cluster_maximum[layer_start:layer_end]
+ cluster_minimum[layer_start:layer_end] = layer_values
+ selected_actnum[layer_start:layer_end] = layer_values
+ if dck.jump_thresholds[0]:
+ depth_difference = _grouped_max(base_depths, cluster_ids) - _grouped_min(
+ top_depths, cluster_ids
+ )
+ if len(dck.jump_thresholds) == 1:
+ removal_mask = (
+ depth_difference < float(dck.jump_thresholds[0]) * mean_cell_height
+ ).astype(int)
+ else:
+ removal_mask = np.zeros(cluster_frequency.size)
+ cells_per_coarse_layer = dck.output_nx * dck.output_ny
+ for layer_index, jump_value in enumerate(dck.jump_thresholds):
+ layer_start = layer_index * cells_per_coarse_layer
+ layer_end = min(
+ layer_start + cells_per_coarse_layer,
+ cluster_frequency.size,
+ )
+ removal_mask[layer_start:layer_end] = (
+ depth_difference[layer_start:layer_end]
+ < float(jump_value) * mean_cell_height[layer_start:layer_end]
+ )
+ dck.output_actnum = (selected_actnum * removal_mask).astype(int)
+ else:
+ removal_mask = np.ones(mean_cell_height.size)
+ dck.output_actnum = selected_actnum.astype(int)
+ pore_volume = np.asarray(dck.original_porv, dtype=float)
+ if dck.dual_porosity_criterion:
+ matrix_pore_volume = _grouped_sum(
+ pore_volume * (coarsening.matrix_mask == 1), cluster_ids
+ )
+ dual_pore_volume = _grouped_sum(
+ pore_volume * (coarsening.matrix_mask == 0), cluster_ids
+ )
+ else:
+ matrix_pore_volume = _grouped_sum(pore_volume, cluster_ids)
+ dual_pore_volume = np.array([], dtype=float)
+ dck.output_porv = matrix_pore_volume
+
+ pycopm_info("coarsening continuous quantities (e.g., PORO)")
+ number_values = dck.output_nx * dck.output_ny * dck.output_nz
+ dual_properties = ("porv", "poro", "tranx", "trany", "tranz")
+ zero_dual_properties = ("permx", "permy", "permz")
+ transmissibility_properties = {"tranx", "trany", "tranz"}
+
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(len(dck.props_keywords + dck.solution_keywords), bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ with bar_ctx as bar_animation:
+ for property_name in dck.props_keywords + dck.solution_keywords:
+ if show_progress:
+ bar_animation()
+ property_values = np.full(dck.original_cell_count, np.nan)
+ if property_name in dck.props_keywords:
+ property_values[dck.original_active_cell_mask] = dck.init_file[
+ property_name.upper()
+ ]
+ else:
+ property_values[dck.original_active_cell_mask] = dck.restart_file[
+ property_name.upper(), 0
+ ]
+ aggregation_property_values = property_values.copy()
+ use_physical_aggregation = (
+ any(not method for method in dck.continuous_aggregation_method)
+ or property_name in transmissibility_properties
+ )
+ if use_physical_aggregation:
+ if property_name in ("permx", "permy"):
+ property_values[dck.original_active_cell_mask] *= d_z[
+ dck.original_active_cell_mask
+ ]
+ elif property_name == "permz":
+ active_permz = property_values[dck.original_active_cell_mask]
+ property_values[dck.original_active_cell_mask] = np.divide(
+ d_z[dck.original_active_cell_mask],
+ active_permz,
+ out=np.full(active_permz.shape, np.nan),
+ where=active_permz > 0,
+ )
+ elif property_name in dck.multipliers_keywords:
+ property_values[dck.original_active_cell_mask] *= total_volume[
+ dck.original_active_cell_mask
+ ]
+ elif property_name in transmissibility_properties:
+ direction = property_name[-1]
+ if (
+ len(coarsening.coarsened_axes) == 1
+ and dck.transmissibility_coarsening_method == 1
+ and direction == coarsening.coarsened_axes[0]
+ ):
+ active_transmissibilities = property_values[
+ dck.original_active_cell_mask
+ ]
+ property_values[dck.original_active_cell_mask] = np.divide(
+ 1.0,
+ active_transmissibilities,
+ out=np.full(active_transmissibilities.shape, np.nan),
+ where=active_transmissibilities > 0,
+ )
+ else:
+ property_values[dck.original_active_cell_mask] *= dck.original_porv[
+ dck.original_active_cell_mask
+ ]
+ dual_values = np.array([], dtype=float)
+ aggregation_dual_values = np.array([], dtype=float)
+ property_dual_c = np.array([], dtype=float)
+ if dck.dual_porosity_criterion:
+ dual_values = property_values.copy()
+ property_values[coarsening.matrix_mask == 0] = 0.0
+ dual_values[coarsening.matrix_mask == 1] = 0.0
+ aggregation_dual_values = np.where(
+ coarsening.matrix_mask == 0,
+ aggregation_property_values,
+ np.nan,
+ )
+ aggregation_property_values = np.where(
+ coarsening.matrix_mask == 1,
+ aggregation_property_values,
+ np.nan,
+ )
+ if property_name in ("tranx", "trany", "tranz"):
+ axis = {"tranx": 2, "trany": 1, "tranz": 0}[property_name]
+ neighboring_mask = np.roll(
+ coarsening.matrix_mask.reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ ),
+ -1,
+ axis=axis,
+ ).ravel()
+ property_values[neighboring_mask == 0] = 0.0
+ dual_values[neighboring_mask == 1] = 0.0
+ aggregation_property_values[neighboring_mask == 0] = np.nan
+ aggregation_dual_values[neighboring_mask == 1] = np.nan
+ if use_physical_aggregation:
+ grouped_values = _grouped_sum(property_values, cluster_ids)
+ grouped_dual_values = (
+ _grouped_sum(dual_values, cluster_ids)
+ if dck.dual_porosity_criterion
+ else np.array([], dtype=float)
+ )
+ if property_name in ("permx", "permy"):
+ thickness = np.asarray(z_tot, dtype=float)
+ values_c = np.divide(
+ grouped_values,
+ thickness,
+ out=np.zeros(grouped_values.shape),
+ where=(thickness * grouped_values) > 0,
+ )
+ if dck.dual_porosity_criterion:
+ dual_thickness = np.asarray(za_tot_dual, dtype=float)
+ property_dual_c = np.divide(
+ grouped_dual_values,
+ dual_thickness,
+ out=np.zeros(grouped_dual_values.shape),
+ where=(dual_thickness * grouped_dual_values) > 0,
+ )
+ elif property_name in ("tranx", "trany"):
+ direction = "x" if property_name == "tranx" else "y"
+ c_tot = x_tot if direction == "x" else y_tot
+ if direction in coarsening.coarsened_axes:
+ if dck.transmissibility_coarsening_method == 1:
+ grouped_minimum = _grouped_min(property_values, cluster_ids)
+ total_length = np.asarray(c_tot, dtype=float)
+ values_c = np.divide(
+ total_length,
+ grouped_values,
+ out=np.zeros(grouped_values.shape),
+ where=(grouped_minimum * grouped_values) > 0,
+ )
+ if dck.dual_porosity_criterion:
+ grouped_dual_minimum = _grouped_min(
+ dual_values, cluster_ids
+ )
+ property_dual_c = np.divide(
+ total_length,
+ grouped_dual_values,
+ out=np.zeros(grouped_dual_values.shape),
+ where=(grouped_dual_minimum * grouped_dual_values)
+ > 0,
+ )
+ else:
+ grouped_average = _grouped_mean(
+ property_values, cluster_ids
+ )
+ grouped_minimum = _grouped_min(property_values, cluster_ids)
+ values_c = np.where(
+ grouped_minimum > 0, grouped_average, 0.0
+ )
+ if dck.dual_porosity_criterion:
+ grouped_dual_average = _grouped_mean(
+ dual_values, cluster_ids
+ )
+ grouped_dual_minimum = _grouped_min(
+ dual_values, cluster_ids
+ )
+ property_dual_c = np.where(
+ grouped_dual_minimum > 0,
+ grouped_dual_average,
+ 0.0,
+ )
+ else:
+ values_c = np.where(grouped_values > 0, grouped_values, 0.0)
+ if dck.dual_porosity_criterion:
+ property_dual_c = np.where(
+ grouped_dual_values > 0,
+ grouped_dual_values,
+ 0.0,
+ )
+ elif property_name == "tranz":
+ if "z" in coarsening.coarsened_axes:
+ if dck.transmissibility_coarsening_method == 1:
+ grouped_minimum = _grouped_min(property_values, cluster_ids)
+ average_active_length = _grouped_mean(d_az, cluster_ids)
+ layer_size = dck.output_nx * dck.output_ny
+ if grouped_values.size > layer_size:
+ grouped_values[:-layer_size] = (
+ (
+ grouped_values[:-layer_size]
+ + grouped_values[layer_size:]
+ )
+ * (grouped_minimum[:-layer_size] > 0)
+ * (grouped_minimum[layer_size:] > 0)
+ )
+ total_length = np.asarray(z_tot, dtype=float)
+ denominator = grouped_values * total_length
+ valid_transmissibility = (grouped_values > 0) & (
+ denominator != 0
+ )
+ values_c = np.divide(
+ average_active_length,
+ denominator,
+ out=np.zeros(grouped_values.shape),
+ where=valid_transmissibility,
+ )
+ if dck.dual_porosity_criterion:
+ grouped_dual_minimum = _grouped_min(
+ dual_values, cluster_ids
+ )
+ average_dual_length = _grouped_mean(
+ d_az * (coarsening.matrix_mask == 0),
+ cluster_ids,
+ )
+ if grouped_dual_values.size > layer_size:
+ grouped_dual_values[:-layer_size] = (
+ (
+ grouped_dual_values[:-layer_size]
+ + grouped_dual_values[layer_size:]
+ )
+ * (grouped_dual_minimum[:-layer_size] > 0)
+ * (grouped_dual_minimum[layer_size:] > 0)
+ )
+ dual_denominator = grouped_dual_values * total_length
+ valid_dual_transmissibility = (
+ grouped_dual_values > 0
+ ) & (dual_denominator != 0)
+ property_dual_c = np.divide(
+ average_dual_length,
+ dual_denominator,
+ out=np.zeros(grouped_dual_values.shape),
+ where=valid_dual_transmissibility,
+ )
+ else:
+ last_transmissibility = _grouped_last(
+ property_values, cluster_ids
+ )
+ last_cell_volume = _grouped_last(cell_volumes, cluster_ids)
+ first_cell_volume = _grouped_first(
+ cell_volumes, cluster_ids
+ )
+ layer_size = dck.original_nx * dck.original_ny
+ shifted_first_volume = np.roll(
+ first_cell_volume, -layer_size
+ )
+ shifted_total_volume = np.roll(total_volume, -layer_size)
+ numerator = last_transmissibility * (
+ last_cell_volume + shifted_first_volume
+ )
+ denominator = total_volume + shifted_total_volume
+ values_c = np.divide(
+ numerator,
+ denominator,
+ out=np.zeros(last_transmissibility.shape),
+ where=(last_transmissibility > 0) & (denominator != 0),
+ )
+ if dck.dual_porosity_criterion:
+ last_dual_transmissibility = _grouped_last(
+ dual_values, cluster_ids
+ )
+ dual_numerator = last_dual_transmissibility * (
+ last_cell_volume + shifted_first_volume
+ )
+ property_dual_c = np.divide(
+ dual_numerator,
+ denominator,
+ out=np.zeros(last_dual_transmissibility.shape),
+ where=(last_dual_transmissibility > 0)
+ & (denominator != 0),
+ )
+ else:
+ active_length = np.asarray(z_a, dtype=float)
+ total_length = np.asarray(z_tot, dtype=float)
+ valid_transmissibility = (grouped_values > 0) & (
+ total_length != 0
+ )
+ values_c = np.divide(
+ grouped_values * active_length,
+ total_length,
+ out=np.zeros(grouped_values.shape),
+ where=valid_transmissibility,
+ )
+ if dck.dual_porosity_criterion:
+ dual_active_length = np.asarray(za_tot_dual, dtype=float)
+ valid_dual_transmissibility = (grouped_dual_values > 0) & (
+ total_length != 0
+ )
+ property_dual_c = np.divide(
+ grouped_dual_values * dual_active_length,
+ total_length,
+ out=np.zeros(grouped_dual_values.shape),
+ where=valid_dual_transmissibility,
+ )
+ elif property_name == "permz":
+ thickness = np.asarray(za_tot, dtype=float)
+ values_c = np.divide(
+ thickness,
+ grouped_values,
+ out=np.zeros(grouped_values.shape),
+ where=(thickness * grouped_values) > 0,
+ )
+ if dck.dual_porosity_criterion:
+ dual_thickness = np.asarray(za_tot_dual, dtype=float)
+ property_dual_c = np.divide(
+ dual_thickness,
+ grouped_dual_values,
+ out=np.zeros(grouped_dual_values.shape),
+ where=(dual_thickness * grouped_dual_values) > 0,
+ )
+ elif property_name in (
+ "poro",
+ "swatinit",
+ "disperc",
+ "thconr",
+ *dck.solution_keywords,
+ ):
+ values_c = np.divide(
+ grouped_values,
+ matrix_pore_volume,
+ out=np.zeros(grouped_values.shape),
+ where=matrix_pore_volume > 0,
+ )
+ if dck.dual_porosity_criterion and property_name == "poro":
+ property_dual_c = np.divide(
+ grouped_dual_values,
+ dual_pore_volume,
+ out=np.zeros(grouped_dual_values.shape),
+ where=dual_pore_volume > 0,
+ )
+ else:
+ values_c = np.divide(
+ grouped_values,
+ total_volume,
+ out=np.zeros(grouped_values.shape),
+ where=total_volume > 0,
+ )
+ elif len(dck.continuous_aggregation_method) == 1:
+ aggregation = dck.continuous_aggregation_method[0]
+ if aggregation == "min":
+ values_c = _grouped_min(aggregation_property_values, cluster_ids)
+ if dck.dual_porosity_criterion:
+ property_dual_c = _grouped_min(
+ aggregation_dual_values, cluster_ids
+ )
+ elif aggregation == "max":
+ values_c = _grouped_max(aggregation_property_values, cluster_ids)
+ if dck.dual_porosity_criterion:
+ property_dual_c = _grouped_max(
+ aggregation_dual_values, cluster_ids
+ )
+ elif aggregation == "pvmean":
+ pv_property_values = aggregation_property_values * dck.original_porv
+ grouped_values = _grouped_sum(pv_property_values, cluster_ids)
+ values_c = np.divide(
+ grouped_values,
+ matrix_pore_volume,
+ out=np.zeros(grouped_values.shape),
+ where=matrix_pore_volume > 0,
+ )
+ if dck.dual_porosity_criterion:
+ pv_dual_values = aggregation_dual_values * dck.original_porv
+ grouped_dual_values = _grouped_sum(pv_dual_values, cluster_ids)
+ property_dual_c = np.divide(
+ grouped_dual_values,
+ dual_pore_volume,
+ out=np.zeros(grouped_dual_values.shape),
+ where=dual_pore_volume > 0,
+ )
+ else:
+ values_c = _grouped_mean(aggregation_property_values, cluster_ids)
+ if dck.dual_porosity_criterion:
+ property_dual_c = _grouped_mean(
+ aggregation_dual_values, cluster_ids
+ )
+ else:
+ grouped_minimum = _grouped_min(aggregation_property_values, cluster_ids)
+ grouped_maximum = _grouped_max(aggregation_property_values, cluster_ids)
+ grouped_mean = _grouped_mean(aggregation_property_values, cluster_ids)
+ pv_property_values = aggregation_property_values * dck.original_porv
+ grouped_pv_values = _grouped_sum(pv_property_values, cluster_ids)
+ grouped_pvmean = np.divide(
+ grouped_pv_values,
+ matrix_pore_volume,
+ out=np.zeros(grouped_pv_values.shape),
+ where=matrix_pore_volume > 0,
+ )
+ values_c = grouped_mean.copy()
+ if dck.dual_porosity_criterion:
+ grouped_dual_minimum = _grouped_min(
+ aggregation_dual_values, cluster_ids
+ )
+ grouped_dual_maximum = _grouped_max(
+ aggregation_dual_values, cluster_ids
+ )
+ grouped_dual_mean = _grouped_mean(
+ aggregation_dual_values, cluster_ids
+ )
+ pv_dual_values = aggregation_dual_values * dck.original_porv
+ grouped_dual_pv_values = _grouped_sum(pv_dual_values, cluster_ids)
+ grouped_dual_pvmean = np.divide(
+ grouped_dual_pv_values,
+ dual_pore_volume,
+ out=np.zeros(grouped_dual_pv_values.shape),
+ where=dual_pore_volume > 0,
+ )
+ property_dual_c = grouped_dual_mean.copy()
+ cells_per_coarse_layer = dck.output_nx * dck.output_ny
+ for layer_index, aggregation in enumerate(
+ dck.continuous_aggregation_method
+ ):
+ layer_start = layer_index * cells_per_coarse_layer
+ layer_end = min(
+ layer_start + cells_per_coarse_layer,
+ values_c.size,
+ )
+ if aggregation == "min":
+ values_c[layer_start:layer_end] = grouped_minimum[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_minimum[layer_start:layer_end]
+ )
+ elif aggregation == "max":
+ values_c[layer_start:layer_end] = grouped_maximum[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_maximum[layer_start:layer_end]
+ )
+ elif aggregation == "pvmean":
+ values_c[layer_start:layer_end] = grouped_pvmean[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_pvmean[layer_start:layer_end]
+ )
+ else:
+ values_c[layer_start:layer_end] = grouped_mean[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = grouped_dual_mean[
+ layer_start:layer_end
+ ]
+ if (
+ len(dck.continuous_aggregation_method) > 1
+ and use_physical_aggregation
+ and property_name not in transmissibility_properties
+ ):
+ grouped_minimum = _grouped_min(aggregation_property_values, cluster_ids)
+ grouped_maximum = _grouped_max(aggregation_property_values, cluster_ids)
+ grouped_mean = _grouped_mean(aggregation_property_values, cluster_ids)
+ pv_property_values = aggregation_property_values * dck.original_porv
+ grouped_pv_values = _grouped_sum(pv_property_values, cluster_ids)
+ grouped_pvmean = np.divide(
+ grouped_pv_values,
+ matrix_pore_volume,
+ out=np.zeros(grouped_pv_values.shape),
+ where=matrix_pore_volume > 0,
+ )
+ if dck.dual_porosity_criterion:
+ grouped_dual_minimum = _grouped_min(
+ aggregation_dual_values, cluster_ids
+ )
+ grouped_dual_maximum = _grouped_max(
+ aggregation_dual_values, cluster_ids
+ )
+ grouped_dual_mean = _grouped_mean(
+ aggregation_dual_values, cluster_ids
+ )
+ pv_dual_values = aggregation_dual_values * dck.original_porv
+ grouped_dual_pv_values = _grouped_sum(pv_dual_values, cluster_ids)
+ grouped_dual_pvmean = np.divide(
+ grouped_dual_pv_values,
+ dual_pore_volume,
+ out=np.zeros(grouped_dual_pv_values.shape),
+ where=dual_pore_volume > 0,
+ )
+ cells_per_coarse_layer = dck.output_nx * dck.output_ny
+ for layer_index, aggregation in enumerate(
+ dck.continuous_aggregation_method
+ ):
+ layer_start = layer_index * cells_per_coarse_layer
+ layer_end = min(
+ layer_start + cells_per_coarse_layer,
+ values_c.size,
+ )
+ if aggregation == "min":
+ values_c[layer_start:layer_end] = grouped_minimum[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_minimum[layer_start:layer_end]
+ )
+ elif aggregation == "max":
+ values_c[layer_start:layer_end] = grouped_maximum[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_maximum[layer_start:layer_end]
+ )
+ elif aggregation == "pvmean":
+ values_c[layer_start:layer_end] = grouped_pvmean[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = (
+ grouped_dual_pvmean[layer_start:layer_end]
+ )
+ elif aggregation:
+ values_c[layer_start:layer_end] = grouped_mean[
+ layer_start:layer_end
+ ]
+ if dck.dual_porosity_criterion:
+ property_dual_c[layer_start:layer_end] = grouped_dual_mean[
+ layer_start:layer_end
+ ]
+ values_c = np.asarray(values_c, dtype=float)
+ values_c[np.isnan(values_c)] = 0.0
+ if property_dual_c.size:
+ property_dual_c = np.asarray(property_dual_c, dtype=float)
+ property_dual_c[np.isnan(property_dual_c)] = 0.0
+ if (
+ dck.coarsening_enabled
+ and dck.transmissibility_coarsening_method > 0
+ and property_name in ["permx", "permy", "permz"]
+ ):
+ well_indices = np.asarray(wellcind, dtype=int)
+ property_values = np.asarray(values_c)
+ keep_values = np.zeros(property_values.size, dtype=bool)
+ keep_values[well_indices] = True
+ values_c = np.where(keep_values, property_values, 0.0)
+ if property_name == "tranx":
+ coarsening.coarse_tranx = values_c
+ if dck.dual_porosity_criterion:
+ coarsening.dual_tranx = property_dual_c
+ elif property_name == "trany":
+ coarsening.coarse_trany = values_c
+ if dck.dual_porosity_criterion:
+ coarsening.dual_trany = property_dual_c
+ if property_name == "permx":
+ permx = values_c
+ if property_name == "permy":
+ permy = values_c
+ if property_name == "permz":
+ permz = values_c
+ property_values = np.asarray(values_c)
+ allow_inline = not dck.dual_porosity_criterion and not (
+ dck.transmissibility_coarsening_method > 0
+ and property_name
+ in (
+ "tranx",
+ "trany",
+ "tranz",
+ "permy",
+ "permz",
+ )
+ )
+ property_inlined = write_property_inc(
+ dck,
+ property_name,
+ values_c,
+ number_values,
+ modified_deck,
+ allow_inline,
+ )
+ if property_inlined:
+ continue
+ generated_files.append(f"{dck.include_prefix}{property_name.upper()}.INC")
+ if dck.dual_porosity_criterion and property_name in [
+ "poro",
+ "tranz",
+ "permx",
+ "permy",
+ "permz",
+ ]:
+ if property_name in dual_properties:
+ dual_values_c = np.asarray(property_dual_c)
+ elif property_name in zero_dual_properties:
+ dual_values_c = np.zeros_like(property_values)
+ else:
+ dual_values_c = property_values
+ property_values = _interleave_dual_property(
+ property_values,
+ dual_values_c,
+ dck.output_nx,
+ dck.output_nz,
+ )
+ write_property_inc(
+ dck,
+ property_name,
+ property_values,
+ property_values.size,
+ modified_deck,
+ False,
+ "_DUAL_TMP_PYCOPM",
+ )
+ if dck.dual_porosity_criterion:
+ property_values = np.asarray(dck.output_porv)
+ property_name = "porv"
+ property_values = _interleave_dual_property(
+ property_values,
+ dual_pore_volume,
+ dck.output_nx,
+ dck.output_nz,
+ )
+ write_property_inc(
+ dck,
+ property_name,
+ property_values,
+ property_values.size,
+ modified_deck,
+ False,
+ "_DUAL_TMP_PYCOPM",
+ )
+ removed = _compact_permeability_properties(dck, permx, permy, permz, modified_deck)
+ if "PERMY" in removed:
+ generated_files.remove(f"{dck.include_prefix}PERMY.INC")
+ if "PERMZ" in removed:
+ generated_files.remove(f"{dck.include_prefix}PERMZ.INC")
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(len(dck.regions_keywords + dck.grids_keywords), bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ pycopm_info("coarsening discrete quantities (e.g., SATNUM)")
+ with bar_ctx as bar_animation:
+ for property_name in dck.regions_keywords + dck.grids_keywords:
+ if show_progress:
+ bar_animation()
+ values = np.full(dck.original_cell_count, np.nan)
+ values[dck.original_active_cell_mask] = dck.init_file[property_name.upper()]
+ property_values = np.asarray(values, dtype=float)
+ valid_property_values = property_values[~np.isnan(property_values)]
+ default_value = 0.0
+ if valid_property_values.size == 0:
+ grouped_values = np.full(int(cluster_ids.max()), np.nan)
+ elif np.max(valid_property_values) == np.min(valid_property_values):
+ default_value = float(valid_property_values[0])
+ coarsening.dual_defaults[property_name] = default_value
+ grouped_values = _grouped_min(property_values, cluster_ids)
+ elif len(dck.discrete_aggregation_method) == 1:
+ aggregation = dck.discrete_aggregation_method[0]
+ if aggregation == "min":
+ grouped_values = _grouped_min(property_values, cluster_ids)
+ elif aggregation == "max":
+ grouped_values = _grouped_max(property_values, cluster_ids)
+ else:
+ group_codes = np.asarray(coarsening.cell_groups, dtype=np.intp) - 1
+ number_groups = int(group_codes.max()) + 1
+ grouped_values = _grouped_mode(
+ property_values,
+ group_codes,
+ number_groups,
+ )
+ else:
+ grouped_minimum = _grouped_min(property_values, cluster_ids)
+ grouped_maximum = _grouped_max(property_values, cluster_ids)
+ group_codes = np.asarray(coarsening.cell_groups, dtype=np.intp) - 1
+ number_groups = int(group_codes.max()) + 1
+ grouped_mode = _grouped_mode(
+ property_values,
+ group_codes,
+ number_groups,
+ )
+ grouped_values = grouped_mode.copy()
+ cells_per_coarse_layer = dck.output_nx * dck.output_ny
+ for layer_index, aggregation in enumerate(
+ dck.discrete_aggregation_method
+ ):
+ layer_start = layer_index * cells_per_coarse_layer
+ layer_end = min(
+ layer_start + cells_per_coarse_layer,
+ grouped_values.size,
+ )
+ if aggregation == "min":
+ layer_values = grouped_minimum[layer_start:layer_end]
+ elif aggregation == "max":
+ layer_values = grouped_maximum[layer_start:layer_end]
+ else:
+ layer_values = grouped_mode[layer_start:layer_end]
+ grouped_values[layer_start:layer_end] = layer_values
+ values_c = np.where(
+ np.isnan(grouped_values), default_value, grouped_values
+ ).astype(int)
+ property_inlined = write_property_inc(
+ dck,
+ property_name,
+ values_c,
+ number_values,
+ modified_deck,
+ not dck.dual_porosity_criterion,
+ )
+ generated_files.append(f"{dck.include_prefix}{property_name.upper()}.INC")
+ if property_inlined:
+ continue
+ if dck.dual_porosity_criterion:
+ default_value = coarsening.dual_defaults.get(property_name, 0)
+ if property_name == "fluxnum":
+ matrix_values = np.ones_like(values_c)
+ dual_values = np.full_like(values_c, 2)
+ else:
+ matrix_values = np.asarray(values_c)
+ dual_values = matrix_values
+ property_values = _interleave_dual_property(
+ matrix_values,
+ dual_values,
+ dck.output_nx,
+ dck.output_nz,
+ default_value,
+ )
+ write_property_inc(
+ dck,
+ property_name,
+ property_values,
+ property_values.size,
+ modified_deck,
+ False,
+ "_DUAL_TMP_PYCOPM",
+ )
+
+ write_reference_to_coarse_map(dck, np.array(coarsening.reference_to_coarse))
+
+ generated_files.append(f"{dck.original_deck_name}_OPERNUM_PYCOPM_REFTOCOA.INC")
+
+ return cluster_minimum, cluster_maximum, removal_mask, generated_files
+
+
+
+
+[docs]
+def _interleave_dual_property(
+ property_values: NDArray,
+ dual_values: NDArray,
+ nx: int,
+ nz: int,
+ default_value: float = 0,
+) -> NDArray:
+ """Interleave property and dual-property layers with separator rows."""
+ property_values = np.asarray(property_values)
+ dual_values = np.asarray(dual_values)
+ cells_per_layer = property_values.size // nz
+ output_dtype = np.result_type(
+ property_values.dtype,
+ dual_values.dtype,
+ np.asarray(default_value).dtype,
+ )
+ property_values = property_values.astype(output_dtype, copy=False)
+ dual_values = dual_values.astype(output_dtype, copy=False)
+ separator_values = np.full(
+ nx,
+ default_value,
+ dtype=output_dtype,
+ )
+ property_blocks: list[NDArray] = []
+ for layer_index in range(nz):
+ layer_slice = slice(
+ layer_index * cells_per_layer,
+ (layer_index + 1) * cells_per_layer,
+ )
+ property_blocks.append(property_values[layer_slice])
+ property_blocks.append(separator_values)
+ property_blocks.append(dual_values[layer_slice])
+ return np.concatenate(property_blocks)
+
+
+
+
+[docs]
+def _find_include_statement(
+ modified_deck: list[str],
+ include_line: str,
+) -> tuple[int, int]:
+ """Return the list interval containing an INCLUDE statement."""
+ include_index = modified_deck.index(include_line)
+ start_index = include_index
+ for index in range(include_index - 1, -1, -1):
+ line = modified_deck[index]
+ code = line.split("--", maxsplit=1)[0]
+ if not code.strip():
+ continue
+ if re.fullmatch(r"\s*INCLUDE\s*", code, flags=re.IGNORECASE):
+ start_index = index
+ break
+ return start_index, include_index + 1
+
+
+
+
+[docs]
+def _compact_permeability_properties(
+ dck: ConfigViaDeck,
+ permx: NDArray,
+ permy: NDArray,
+ permz: NDArray,
+ modified_deck: list[str],
+) -> list[str]:
+ """Use COPY and MULTIPLY if PERMY and PERMZ can be generated from PERMX."""
+ removed: list[str] = []
+ copy_permy = np.array_equal(permx, permy)
+ copy_permz = False
+ output_path = Path(dck.output_directory)
+ permx_values = np.asarray(dck.init_file["PERMX"])
+ permz_values = np.asarray(dck.init_file["PERMZ"])
+ valid_permeability = (permx_values != 0) & (permz_values != 0)
+ facpermz = -1.0
+ if np.any(valid_permeability):
+ first_valid_index = int(np.flatnonzero(valid_permeability)[0])
+ facpermz = float(
+ permz_values[first_valid_index] / permx_values[first_valid_index]
+ )
+ if facpermz > 0 and np.all(np.abs(permz - facpermz * permx) <= 1e-12):
+ copy_permz = True
+ include_statements = []
+ if copy_permy:
+ include_line = f"'{dck.include_prefix}PERMY.INC' /\n"
+ include_statements.append(_find_include_statement(modified_deck, include_line))
+ if copy_permz:
+ include_line = f"'{dck.include_prefix}PERMZ.INC' /\n"
+ include_statements.append(_find_include_statement(modified_deck, include_line))
+ if not include_statements:
+ return removed
+ insertion_index = min(start_index for start_index, _ in include_statements)
+ for start_index, end_index in sorted(include_statements, reverse=True):
+ del modified_deck[start_index:end_index]
+ if copy_permy and copy_permz:
+ text = "COPY\nPERMX PERMY /\nPERMX PERMZ /\n/\n"
+ if abs(1 - facpermz) > 1e-12:
+ text += f"\nMULTIPLY\nPERMZ {facpermz:.4E} /\n/\n"
+ modified_deck.insert(insertion_index, text)
+ elif copy_permy:
+ modified_deck.insert(
+ insertion_index,
+ "COPY\nPERMX PERMY /\n/\n",
+ )
+ elif copy_permz:
+ text = "COPY\nPERMX PERMZ /\n/"
+ if abs(1 - facpermz) > 1e-12:
+ text += f"\nMULTIPLY\nPERMZ {facpermz:.4E} /\n/\n"
+ modified_deck.insert(insertion_index, text)
+ if copy_permy:
+ permy_path = output_path / f"{dck.include_prefix}PERMY.INC"
+ permy_path.unlink(missing_ok=True)
+ permy_path = output_path / f"{dck.include_prefix}PERMY_DUAL_TMP_PYCOPM.INC"
+ permy_path.unlink(missing_ok=True)
+ removed.append("PERMY")
+ if copy_permz:
+ permz_path = output_path / f"{dck.include_prefix}PERMZ.INC"
+ permz_path.unlink(missing_ok=True)
+ permz_path = output_path / f"{dck.include_prefix}PERMZ_DUAL_TMP_PYCOPM.INC"
+ permz_path.unlink(missing_ok=True)
+ removed.append("PERMZ")
+ return removed
+
+
+
+
+[docs]
+def redistribute_removed_pore_volume(
+ dck: ConfigViaDeck,
+ con: NDArray,
+ cluster_minimum: NDArray,
+ cluster_maximum: NDArray,
+ removal_mask: NDArray,
+) -> None:
+ """Redistribute pore volume from removed coarse cells.
+
+ Pore volume is divided among the nearest active neighbours without changing
+ the total pore volume.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose ``output_porv`` is updated.
+ con
+ One-based coarse-cell identifier for each original cell.
+ cluster_minimum, cluster_maximum
+ Aggregated activity values used to identify changed clusters.
+ removal_mask
+ Mask identifying retained coarse cells."""
+ cluster_ids = np.asarray(con, dtype=int)
+ pore_volumes = np.asarray(dck.original_porv, dtype=float)
+ dck.output_porv = np.asarray(dck.output_porv, dtype=float)
+ grouped_pore_volume = np.bincount(
+ cluster_ids,
+ weights=pore_volumes,
+ minlength=int(np.max(cluster_ids)) + 1,
+ )
+ cluster_minimum = np.asarray(cluster_minimum)
+ cluster_maximum = np.asarray(cluster_maximum)
+ removal_mask = np.asarray(removal_mask)
+ changed_clusters = np.flatnonzero(cluster_maximum - cluster_minimum > 0) + 1
+ removed_clusters = np.flatnonzero(removal_mask == 0) + 1
+ redistribution_clusters = np.union1d(changed_clusters, removed_clusters)
+ maximum_distance = max(dck.output_nx, dck.output_ny, dck.output_nz)
+ total_cells = dck.output_nx * dck.output_ny * dck.output_nz
+ for cluster_value in redistribution_clusters:
+ cluster_id = int(cluster_value)
+ i, j, k = _global_index_to_ijk(dck, cluster_id - 1)
+ neighbor_indices: list[int] = []
+ distance = 0
+ offset = 0
+ while not neighbor_indices and offset < total_cells:
+ neighbor_indices = _find_active_neighbors(
+ dck,
+ neighbor_indices,
+ cluster_id,
+ distance,
+ offset,
+ [i, j, k],
+ )
+ distance += 1
+ if distance > maximum_distance:
+ distance = 0
+ offset += 1
+ if not neighbor_indices:
+ pycopm_error(
+ "no active cell found to receive pore volume from "
+ f"cluster {cluster_id}"
+ )
+ pore_volume_increment = grouped_pore_volume[cluster_id] / len(neighbor_indices)
+ dck.output_porv[
+ np.asarray(neighbor_indices, dtype=int)
+ ] += pore_volume_increment
+
+
+
+
+[docs]
+def _find_active_neighbors(
+ dck: ConfigViaDeck,
+ neighbor_indices: list[int],
+ cluster_id: int,
+ distance: int,
+ offset: int,
+ ijk: list,
+) -> list[int]:
+ """Find active neighbouring cells for pore-volume redistribution."""
+ total_cells = dck.output_nx * dck.output_ny * dck.output_nz
+ candidates = (
+ (ijk[0] + 1 + distance < dck.output_nx, cluster_id + distance + offset),
+ (ijk[0] - 1 - distance >= 0, cluster_id - 2 - distance + offset),
+ (
+ ijk[1] + 1 + distance < dck.output_ny,
+ cluster_id - 1 + (distance + 1) * dck.output_nx + offset,
+ ),
+ (
+ ijk[1] - 1 - distance >= 0,
+ cluster_id - 1 - (distance + 1) * dck.output_nx + offset,
+ ),
+ (
+ ijk[2] + 1 + distance < dck.output_nz,
+ cluster_id - 1 + (distance + 1) * dck.output_nx * dck.output_ny + offset,
+ ),
+ (
+ ijk[2] - 1 - distance >= 0,
+ cluster_id - 1 - (distance + 1) * dck.output_nx * dck.output_ny + offset,
+ ),
+ )
+ for valid_direction, candidate_index in candidates:
+ if (
+ valid_direction
+ and 0 <= candidate_index < total_cells
+ and dck.output_actnum[candidate_index] == 1
+ ):
+ neighbor_indices.append(candidate_index)
+ return neighbor_indices
+
+
+
+
+[docs]
+def _global_index_to_ijk(dck: ConfigViaDeck, global_index: int) -> tuple[int, int, int]:
+ """Return the i, j, and k indices from a zero-based global cell index."""
+ cells_per_layer = dck.output_nx * dck.output_ny
+ k_index, layer_index = divmod(global_index, cells_per_layer)
+ j_index, i_index = divmod(layer_index, dck.output_nx)
+ return i_index, j_index, k_index
+
+
+
+
+[docs]
+def coarsen_corner_point_grid(
+ dck: ConfigViaDeck, coarsening: CoarseningMaps
+) -> tuple[NDArray, NDArray]:
+ """Remove selected pillars and ZCORN surfaces from the grid.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing the original corner-point grid.
+ coarsening
+ Axis mappings defining the removed rows, columns, and layers.
+
+ Returns
+ -------
+ coord, zcorn
+ Coarsened arrays when dual porosity is enabled; otherwise empty arrays."""
+ original_nx = dck.original_nx
+ original_ny = dck.original_ny
+ original_nz = dck.original_nz
+ pillars_per_row = original_nx + 1
+ pillar_count = pillars_per_row * (original_ny + 1)
+ cells_per_layer = original_nx * original_ny
+ total_zcorn_values = 8 * cells_per_layer * original_nz
+ coord_values = np.asarray(dck.egrid_file["COORD"]).reshape(-1)
+ zcorn_values = np.asarray(dck.egrid_file["ZCORN"]).reshape(-1)
+ removed_columns = np.flatnonzero(np.asarray(coarsening.x) > 1)
+ removed_rows = np.flatnonzero(np.asarray(coarsening.y) > 1)
+ removed_pillar_mask = np.zeros(
+ (original_ny + 1, pillars_per_row),
+ dtype=bool,
+ )
+ removed_pillar_mask[:, removed_columns] = True
+ removed_pillar_mask[removed_rows, :] = True
+ coord_matrix = coord_values.reshape(pillar_count, 6)
+ coarsened_coord_values = coord_matrix[~removed_pillar_mask.ravel()].reshape(-1)
+ zcorn_removal_mask = np.zeros(total_zcorn_values, dtype=bool)
+ column_offsets = np.arange(2, dtype=np.intp)
+ row_offsets = np.arange(4 * original_nx, dtype=np.intp)
+ for column_index in removed_columns:
+ column_starts = np.arange(
+ 2 * column_index - 1,
+ total_zcorn_values,
+ 2 * original_nx,
+ dtype=np.intp,
+ )
+ column_indices = (column_starts[:, None] + column_offsets).reshape(-1)
+ zcorn_removal_mask[column_indices] = True
+ for row_index in removed_rows:
+ row_starts = np.arange(
+ (2 * row_index - 1) * 2 * original_nx,
+ total_zcorn_values,
+ 4 * cells_per_layer,
+ dtype=np.intp,
+ )
+ row_indices = (row_starts[:, None] + row_offsets).reshape(-1)
+ zcorn_removal_mask[row_indices] = True
+ zcorn_removal_indices = np.flatnonzero(zcorn_removal_mask).tolist()
+ zcorn_removal_indices = _collect_removed_zcorn_indices(
+ dck,
+ coarsening.z,
+ zcorn_removal_indices,
+ )
+ final_zcorn_removal_indices = np.asarray(
+ zcorn_removal_indices,
+ dtype=np.intp,
+ )
+ zcorn_removal_mask.fill(False)
+ if final_zcorn_removal_indices.size:
+ zcorn_removal_mask[final_zcorn_removal_indices] = True
+ coarsened_zcorn_values = zcorn_values[~zcorn_removal_mask]
+ write_grid(
+ dck,
+ coarsened_coord_values,
+ coarsened_zcorn_values,
+ False,
+ )
+ if dck.dual_porosity_criterion:
+ return coarsened_coord_values, coarsened_zcorn_values
+ return np.array([]), np.array([])
+
+
+
+
+[docs]
+def build_dual_porosity_grid(
+ dck: ConfigViaDeck, coarsening: CoarseningMaps, cr: NDArray, zc: NDArray
+) -> tuple[NDArray, NDArray]:
+ """Extend a coarsened grid with a second porosity continuum.
+
+ The matrix and fracture grids are separated in the j direction, and their
+ connections are added to ``coarsening.nnc_text``.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration for the coarsened model.
+ coarsening
+ Coarsening data containing continuum masks and transmissibilities.
+ cr, zc
+ Coarsened ``COORD`` and ``ZCORN`` arrays.
+
+ Returns
+ -------
+ coord, zcorn
+ Extended dual-porosity grid arrays."""
+ num_dig = dck.significant_digits
+ cells_per_layer = dck.output_nx * dck.output_ny
+ coord_values = np.asarray(cr, dtype=float)
+ y_offset = 1.075 * max(
+ coord_values[-6 * (dck.output_nx + 1) + 1] - coord_values[1],
+ coord_values[-2] - coord_values[6 * (dck.output_nx + 1) - 2],
+ )
+ shifted_coord = coord_values.copy()
+ shifted_coord[1::3] += y_offset
+ cr = np.concatenate((coord_values, shifted_coord))
+ zcorn_values = np.asarray(zc, dtype=float)
+ values_per_surface = 4 * cells_per_layer
+ zcorn_blocks: list[NDArray] = []
+ for surface_index in range(zcorn_values.size // values_per_surface):
+ surface_start = surface_index * values_per_surface
+ surface_end = surface_start + values_per_surface
+ surface_values = zcorn_values[surface_start:surface_end]
+ zcorn_blocks.extend(
+ (
+ surface_values,
+ surface_values[-2 * dck.output_nx :],
+ surface_values[: 2 * dck.output_nx],
+ surface_values,
+ )
+ )
+ zc = np.concatenate(zcorn_blocks)
+ mask_values = np.asarray(coarsening.matrix_mask)
+ values = np.full(dck.original_cell_count, np.nan)
+ values[dck.original_active_cell_mask] = dck.init_file["TRANX"]
+ tranx_values = np.asarray(values, dtype=float)
+ values = np.full(dck.original_cell_count, np.nan)
+ values[dck.original_active_cell_mask] = dck.init_file["TRANY"]
+ trany_values = np.asarray(values, dtype=float)
+ values = np.full(dck.original_cell_count, np.nan)
+ values[dck.original_active_cell_mask] = dck.init_file["TRANZ"]
+ tranz_values = np.asarray(values, dtype=float)
+ porv_values = np.asarray(dck.output_porv, dtype=float)
+ cluster_ids = np.asarray(coarsening.cell_groups, dtype=int)
+ dual_porv_values = _grouped_sum(
+ dck.original_porv * (coarsening.matrix_mask == 0), cluster_ids
+ )
+ nnc_lines: list[str] = []
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(cells_per_layer * dck.output_nz, bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ pycopm_info("processing the dual connectivity")
+ with bar_ctx as bar_animation:
+ for row_index in range(dck.original_ny):
+ for column_index in range(dck.original_nx):
+ original_layer = 0
+ for layer_index in range(dck.output_nz):
+ if show_progress:
+ bar_animation()
+ positive_x = 0.0
+ negative_x = 0.0
+ positive_y = 0.0
+ negative_y = 0.0
+ vertical = 0.0
+ while (
+ original_layer + 1 < len(coarsening.z)
+ and coarsening.z[original_layer + 1] == 2
+ ):
+ original_index = (
+ column_index
+ + row_index * dck.original_nx
+ + original_layer * cells_per_layer
+ )
+ coarse_index = (
+ column_index
+ + row_index * dck.original_nx
+ + layer_index * cells_per_layer
+ )
+ if (
+ original_index + cells_per_layer < mask_values.size
+ and mask_values[original_index]
+ != mask_values[original_index + cells_per_layer]
+ and dual_porv_values[coarse_index] > 0
+ and porv_values[coarse_index] > 0
+ and not np.isnan(tranz_values[original_index])
+ and coarsening.vertical_transfer_enabled
+ ):
+ vertical += tranz_values[original_index]
+ if (
+ column_index < dck.original_nx - 1
+ and mask_values[original_index] == 1
+ and mask_values[original_index + 1] == 0
+ and dual_porv_values[coarse_index + 1] > 0
+ and not np.isnan(tranx_values[original_index])
+ ):
+ positive_x += tranx_values[original_index]
+ if (
+ column_index > 0
+ and mask_values[original_index - 1] == 0
+ and mask_values[original_index] == 1
+ and dual_porv_values[coarse_index - 1] > 0
+ and not np.isnan(tranx_values[original_index - 1])
+ ):
+ negative_x += tranx_values[original_index - 1]
+ if (
+ row_index < dck.original_ny - 1
+ and mask_values[original_index] == 1
+ and mask_values[original_index + dck.original_nx] == 0
+ and dual_porv_values[coarse_index + dck.original_nx] > 0
+ and not np.isnan(trany_values[original_index])
+ ):
+ positive_y += trany_values[original_index]
+ if (
+ row_index > 0
+ and mask_values[original_index - dck.original_nx] == 0
+ and mask_values[original_index] == 1
+ and dual_porv_values[coarse_index - dck.original_nx] > 0
+ and not np.isnan(
+ trany_values[original_index - dck.original_nx]
+ )
+ ):
+ negative_y += trany_values[original_index - dck.original_nx]
+ original_layer += 1
+ original_index = (
+ column_index
+ + row_index * dck.original_nx
+ + original_layer * cells_per_layer
+ )
+ coarse_index = (
+ column_index
+ + row_index * dck.original_nx
+ + layer_index * cells_per_layer
+ )
+ if (
+ column_index < dck.original_nx - 1
+ and mask_values[original_index] == 1
+ and mask_values[original_index + 1] == 0
+ and dual_porv_values[coarse_index + 1] > 0
+ and not np.isnan(tranx_values[original_index])
+ ):
+ positive_x += tranx_values[original_index]
+ if (
+ column_index > 0
+ and mask_values[original_index - 1] == 0
+ and mask_values[original_index] == 1
+ and dual_porv_values[coarse_index - 1] > 0
+ and not np.isnan(tranx_values[original_index - 1])
+ ):
+ negative_x += tranx_values[original_index - 1]
+ if (
+ row_index < dck.original_ny - 1
+ and mask_values[original_index] == 1
+ and mask_values[original_index + dck.original_nx] == 0
+ and dual_porv_values[coarse_index + dck.original_nx] > 0
+ and not np.isnan(trany_values[original_index])
+ ):
+ positive_y += trany_values[original_index]
+ if (
+ row_index > 0
+ and mask_values[original_index - dck.original_nx] == 0
+ and mask_values[original_index] == 1
+ and dual_porv_values[coarse_index - dck.original_nx] > 0
+ and not np.isnan(trany_values[original_index - dck.original_nx])
+ ):
+ negative_y += trany_values[original_index - dck.original_nx]
+ original_layer += 1
+ matrix_row = row_index + 1
+ fracture_row = row_index + dck.original_ny + 2
+ output_layer = layer_index + 1
+ if vertical > 0:
+ nnc_lines.append(
+ f"{column_index + 1} {matrix_row} {output_layer} "
+ f"{column_index + 1} {fracture_row} {output_layer} "
+ f"{round_like_e([vertical],num_dig)[0]} /\n"
+ )
+ if positive_x > 0:
+ nnc_lines.append(
+ f"{column_index + 1} {matrix_row} {output_layer} "
+ f"{column_index + 2} {fracture_row} {output_layer} "
+ f"{round_like_e([positive_x],num_dig)[0]} /\n"
+ )
+ if negative_x > 0:
+ nnc_lines.append(
+ f"{column_index + 1} {matrix_row} {output_layer} "
+ f"{column_index} {fracture_row} {output_layer} "
+ f"{round_like_e([negative_x],num_dig)[0]} /\n"
+ )
+ if positive_y > 0:
+ nnc_lines.append(
+ f"{column_index + 1} {matrix_row} {output_layer} "
+ f"{column_index + 1} {fracture_row + 1} "
+ f"{output_layer} {round_like_e([positive_y],num_dig)[0]} /\n"
+ )
+ if negative_y > 0:
+ nnc_lines.append(
+ f"{column_index + 1} {matrix_row} {output_layer} "
+ f"{column_index + 1} {fracture_row - 1} "
+ f"{output_layer} {round_like_e([negative_y],num_dig)[0]} /\n"
+ )
+ coarsening.nnc_text += "".join(nnc_lines)
+
+ for property_name in ["actnum", "tranx", "trany"]:
+ default_value = coarsening.dual_defaults.get(property_name, 0)
+ if property_name == "tranx":
+ source_values = coarsening.coarse_tranx
+ elif property_name == "trany":
+ source_values = coarsening.coarse_trany
+ else:
+ source_values = dck.output_actnum
+ property_blocks: list[NDArray] = []
+ for layer_index in range(dck.output_nz):
+ layer_slice = slice(
+ layer_index * cells_per_layer,
+ (layer_index + 1) * cells_per_layer,
+ )
+ property_blocks.append(source_values[layer_slice])
+ property_blocks.append(
+ np.full(
+ dck.output_nx,
+ default_value,
+ dtype=source_values.dtype,
+ )
+ )
+ if property_name == "tranx":
+ property_blocks.append(np.asarray(coarsening.dual_tranx)[layer_slice])
+ elif property_name == "trany":
+ property_blocks.append(np.asarray(coarsening.dual_trany)[layer_slice])
+ else:
+ property_blocks.append(source_values[layer_slice])
+ if property_name == "tranx":
+ coarsening.coarse_tranx = np.concatenate(property_blocks)
+ elif property_name == "trany":
+ coarsening.coarse_trany = np.concatenate(property_blocks)
+ else:
+ setattr(dck, f"output_{property_name}", np.concatenate(property_blocks))
+ return cr, zc
+
+
+
+
+[docs]
+def _collect_removed_zcorn_indices(
+ dck: ConfigViaDeck, coa_z: NDArray, removal_indices: list[int]
+) -> list[int]:
+ """Add the ZCORN indices removed by vertical coarsening."""
+ cells_per_layer = 4 * dck.original_nx * dck.original_ny
+ for layer_index in range(dck.original_nz + 1):
+ if coa_z[layer_index] > 1:
+ removal_indices.extend(
+ range(
+ (2 * layer_index - 1) * cells_per_layer,
+ (2 * layer_index + 1) * cells_per_layer,
+ )
+ )
+ return removal_indices
+
+
+
+
+[docs]
+def map_nnc_transmissibilities(
+ dck: ConfigViaDeck, coarsening: CoarseningMaps
+) -> list[str]:
+ """Map original non-neighbouring transmissibilities to the coarse grid.
+
+ Connections that become Cartesian neighbours are accumulated in ``TRANX`` or
+ ``TRANY``; remaining connections are written as NNC records.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration and source NNC data.
+ coarsening
+ Coarse mapping updated with transmissibilities and NNC text.
+
+ Returns
+ -------
+ generated_files
+ Names of the written include files."""
+ generated_files = []
+ output_directory = Path(dck.output_directory)
+ original_grid = OpmFile(f"{dck.input_deck_name}.EGRID")
+ coarsened_init = OpmFile(str(output_directory / f"{dck.output_deck_name}.INIT"))
+ num_dig = dck.significant_digits
+ first_connection_cells = np.asarray(original_grid["NNC1"], dtype=np.intp).reshape(
+ -1
+ )
+ second_connection_cells = np.asarray(original_grid["NNC2"], dtype=np.intp).reshape(
+ -1
+ )
+ connection_transmissibilities = np.asarray(
+ dck.init_file["TRANNNC"],
+ dtype=float,
+ ).reshape(-1)
+ connection_count = min(
+ first_connection_cells.size,
+ second_connection_cells.size,
+ connection_transmissibilities.size,
+ )
+ first_cell_indices = first_connection_cells[:connection_count] - 1
+ second_cell_indices = second_connection_cells[:connection_count] - 1
+ connection_transmissibilities = connection_transmissibilities[:connection_count]
+ coarsened_porv = np.asarray(coarsened_init["PORV"], dtype=float).reshape(-1)
+ tranx_c = np.zeros(coarsened_porv.size, dtype=float)
+ trany_c = np.zeros(coarsened_porv.size, dtype=float)
+ active_cells = coarsened_porv > 0
+ input_tranx = np.asarray(coarsened_init["TRANX"], dtype=float).reshape(-1)
+ input_trany = np.asarray(coarsened_init["TRANY"], dtype=float).reshape(-1)
+ if input_tranx.size == coarsened_porv.size:
+ tranx_c[active_cells] = input_tranx[active_cells]
+ elif input_tranx.size == np.count_nonzero(active_cells):
+ tranx_c[active_cells] = input_tranx
+ if input_trany.size == coarsened_porv.size:
+ trany_c[active_cells] = input_trany[active_cells]
+ elif input_trany.size == np.count_nonzero(active_cells):
+ trany_c[active_cells] = input_trany
+ cells_per_layer = dck.original_nx * dck.original_ny
+ first_cell_i = first_cell_indices % dck.original_nx
+ first_cell_j = first_cell_indices // dck.original_nx % dck.original_ny
+ first_cell_k = first_cell_indices // cells_per_layer
+ second_cell_i = second_cell_indices % dck.original_nx
+ second_cell_j = second_cell_indices // dck.original_nx % dck.original_ny
+ second_cell_k = second_cell_indices // cells_per_layer
+ output_i_map = np.fromiter(
+ (
+ dck.original_to_output_i[original_index]
+ for original_index in range(1, dck.original_nx + 1)
+ ),
+ dtype=np.intp,
+ count=dck.original_nx,
+ )
+ output_j_map = np.fromiter(
+ (
+ dck.original_to_output_j[original_index]
+ for original_index in range(1, dck.original_ny + 1)
+ ),
+ dtype=np.intp,
+ count=dck.original_ny,
+ )
+ output_k_map = np.fromiter(
+ (
+ dck.original_to_output_k[original_index]
+ for original_index in range(1, dck.original_nz + 1)
+ ),
+ dtype=np.intp,
+ count=dck.original_nz,
+ )
+ first_output_i = output_i_map[first_cell_i]
+ first_output_j = output_j_map[first_cell_j]
+ first_output_k = output_k_map[first_cell_k]
+ second_output_i = output_i_map[second_cell_i]
+ second_output_j = output_j_map[second_cell_j]
+ second_output_k = output_k_map[second_cell_k]
+ coarsening_mask = np.asarray(coarsening.matrix_mask).reshape(-1)
+ first_cell_mask = coarsening_mask[first_cell_indices]
+ second_cell_mask = coarsening_mask[second_cell_indices]
+ different_continuum = first_cell_mask != second_cell_mask
+ same_output_layer = first_output_k == second_output_k
+ different_horizontal_cell = (first_cell_i != second_cell_i) | (
+ first_cell_j != second_cell_j
+ )
+ horizontal_candidate = (
+ ~different_continuum & same_output_layer & different_horizontal_cell
+ )
+ positive_i_neighbour = horizontal_candidate & (first_cell_i + 1 == second_cell_i)
+ negative_i_neighbour = horizontal_candidate & (first_cell_i == second_cell_i + 1)
+ positive_j_neighbour = horizontal_candidate & (first_cell_j + 1 == second_cell_j)
+ negative_j_neighbour = horizontal_candidate & (first_cell_j == second_cell_j + 1)
+ first_coarse_indices = (
+ first_output_i
+ - 1
+ + (first_output_j - 1) * dck.output_nx
+ + (first_output_k - 1) * dck.output_nx * dck.output_ny
+ )
+ second_coarse_indices = (
+ second_output_i
+ - 1
+ + (second_output_j - 1) * dck.output_nx
+ + (second_output_k - 1) * dck.output_nx * dck.output_ny
+ )
+ positive_i_matrix = positive_i_neighbour & (first_cell_mask == 0)
+ positive_i_fracture = positive_i_neighbour & (first_cell_mask != 0)
+ negative_i_matrix = negative_i_neighbour & (first_cell_mask == 0)
+ negative_i_fracture = negative_i_neighbour & (first_cell_mask != 0)
+ positive_j_matrix = positive_j_neighbour & (first_cell_mask == 0)
+ positive_j_fracture = positive_j_neighbour & (first_cell_mask != 0)
+ negative_j_matrix = negative_j_neighbour & (first_cell_mask == 0)
+ negative_j_fracture = negative_j_neighbour & (first_cell_mask != 0)
+ np.add.at(
+ coarsening.dual_tranx,
+ first_coarse_indices[positive_i_matrix],
+ connection_transmissibilities[positive_i_matrix],
+ )
+ np.add.at(
+ tranx_c,
+ first_coarse_indices[positive_i_fracture],
+ connection_transmissibilities[positive_i_fracture],
+ )
+ np.add.at(
+ coarsening.dual_tranx,
+ second_coarse_indices[negative_i_matrix],
+ connection_transmissibilities[negative_i_matrix],
+ )
+ np.add.at(
+ tranx_c,
+ second_coarse_indices[negative_i_fracture],
+ connection_transmissibilities[negative_i_fracture],
+ )
+ np.add.at(
+ coarsening.dual_trany,
+ first_coarse_indices[positive_j_matrix],
+ connection_transmissibilities[positive_j_matrix],
+ )
+ np.add.at(
+ trany_c,
+ first_coarse_indices[positive_j_fracture],
+ connection_transmissibilities[positive_j_fracture],
+ )
+ np.add.at(
+ coarsening.dual_trany,
+ second_coarse_indices[negative_j_matrix],
+ connection_transmissibilities[negative_j_matrix],
+ )
+ np.add.at(
+ trany_c,
+ second_coarse_indices[negative_j_fracture],
+ connection_transmissibilities[negative_j_fracture],
+ )
+ nncc_mask = different_continuum | (~different_continuum & ~same_output_layer)
+ nncc_indices = np.flatnonzero(nncc_mask)
+ nncc_lines: list[str] = []
+ for connection_index in nncc_indices:
+ if different_continuum[connection_index]:
+ if first_cell_mask[connection_index] == 1:
+ first_i = first_output_i[connection_index]
+ first_j = first_output_j[connection_index]
+ first_k = first_output_k[connection_index]
+ second_i = second_output_i[connection_index]
+ second_j = second_output_j[connection_index] + 1 + dck.original_ny
+ second_k = second_output_k[connection_index]
+ else:
+ first_i = second_output_i[connection_index]
+ first_j = second_output_j[connection_index]
+ first_k = second_output_k[connection_index]
+ second_i = first_output_i[connection_index]
+ second_j = first_output_j[connection_index] + 1 + dck.original_ny
+ second_k = first_output_k[connection_index]
+ elif first_cell_mask[connection_index] == 1:
+ first_i = first_output_i[connection_index]
+ first_j = first_output_j[connection_index]
+ first_k = first_output_k[connection_index]
+ second_i = second_output_i[connection_index]
+ second_j = second_output_j[connection_index]
+ second_k = second_output_k[connection_index]
+ else:
+ first_i = first_output_i[connection_index]
+ first_j = first_output_j[connection_index] + 1 + dck.original_ny
+ first_k = first_output_k[connection_index]
+ second_i = second_output_i[connection_index]
+ second_j = second_output_j[connection_index] + 1 + dck.original_ny
+ second_k = second_output_k[connection_index]
+ nncc_lines.append(
+ f"{first_i} {first_j} {first_k} "
+ f"{second_i} {second_j} {second_k} "
+ f"{round_like_e([connection_transmissibilities[connection_index]],num_dig)[0]} /\n"
+ )
+ if nncc_lines:
+ coarsening.nnc_text += "".join(nncc_lines)
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(connection_count, bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ pycopm_info("processing non-neighbouring transmissibilities NNC (input model)")
+ with bar_ctx as bar_animation:
+ if show_progress and connection_count:
+ bar_animation(connection_count)
+ if not dck.dual_porosity_criterion:
+ generated_files.append(f"{dck.include_prefix}TRANX.INC")
+ property_path = output_directory / f"{dck.include_prefix}TRANX.INC"
+ write_property(property_path, "TRANX", tranx_c, num_dig)
+ property_path = output_directory / f"{dck.include_prefix}TRANY.INC"
+ write_property(property_path, "TRANY", trany_c, num_dig)
+ else:
+ coarsening.coarse_tranx = tranx_c
+ coarsening.coarse_trany = trany_c
+ return generated_files
+
+
+
+
+[docs]
+def create_coarsening_map(cfg: ConfigViaTOML) -> NDArray:
+ """Map each fine-grid cell to a one-based coarse-cell identifier.
+
+ The output dimensions and original-to-output axis mappings in ``cfg`` are also
+ updated.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration containing the axis coarsening arrays.
+
+ Returns
+ -------
+ NDArray
+ One-based coarse-cell identifier for every fine-grid cell."""
+ cell_number = 0
+ coarse_cell_number = 1
+ nx_fine, ny_fine, nz_fine = cfg.original_nx, cfg.original_ny, cfg.original_nz
+ coa_map = np.zeros(cfg.original_cell_count, dtype=int)
+ for layer_index in range(nz_fine):
+ for row_index in range(ny_fine):
+ for column_index in range(nx_fine):
+ if coa_map[cell_number] == 0:
+ coa_map[cell_number] = coarse_cell_number
+ coarse_cell_number += 1
+ if (
+ column_index + 1 < nx_fine
+ and cfg.x_coarsening[column_index + 1] > 1
+ ):
+ coa_map[cell_number + 1] = coa_map[cell_number]
+ if row_index + 1 < ny_fine and cfg.y_coarsening[row_index + 1] > 1:
+ coa_map[cell_number + nx_fine] = coa_map[cell_number]
+ if layer_index + 1 < nz_fine and cfg.z_coarsening[layer_index + 1] > 1:
+ coa_map[cell_number + nx_fine * ny_fine] = coa_map[cell_number]
+ cell_number += 1
+ cfg.output_nx = nx_fine - int(np.count_nonzero(cfg.x_coarsening == 2))
+ cfg.output_ny = ny_fine - int(np.count_nonzero(cfg.y_coarsening == 2))
+ cfg.output_nz = nz_fine - int(np.count_nonzero(cfg.z_coarsening == 2))
+
+ cfg.original_to_output_i = np.zeros(nx_fine + 1, dtype=int)
+ coarse_index, fine_index = 1, 1
+ for axis_index in range(nx_fine):
+ if cfg.original_to_output_i[fine_index] == 0:
+ cfg.original_to_output_i[fine_index] = coarse_index
+ coarse_index += 1
+ if axis_index + 1 < nx_fine and cfg.x_coarsening[axis_index + 1] > 1:
+ cfg.original_to_output_i[fine_index + 1] = cfg.original_to_output_i[
+ axis_index + 1
+ ]
+ fine_index += 1
+
+ cfg.original_to_output_j = np.zeros(ny_fine + 1, dtype=int)
+ coarse_index, fine_index = 1, 1
+ for axis_index in range(ny_fine):
+ if cfg.original_to_output_j[fine_index] == 0:
+ cfg.original_to_output_j[fine_index] = coarse_index
+ coarse_index += 1
+ if axis_index + 1 < ny_fine and cfg.y_coarsening[axis_index + 1] > 1:
+ cfg.original_to_output_j[fine_index + 1] = cfg.original_to_output_j[
+ axis_index + 1
+ ]
+ fine_index += 1
+
+ cfg.original_to_output_k = np.zeros(nz_fine + 1, dtype=int)
+ coarse_index, fine_index = 1, 1
+ for axis_index in range(nz_fine):
+ if cfg.original_to_output_k[fine_index] == 0:
+ cfg.original_to_output_k[fine_index] = coarse_index
+ coarse_index += 1
+ if axis_index + 1 < nz_fine and cfg.z_coarsening[axis_index + 1] > 1:
+ cfg.original_to_output_k[fine_index + 1] = cfg.original_to_output_k[
+ axis_index + 1
+ ]
+ fine_index += 1
+
+ return coa_map
+
+
+
+
+[docs]
+def _group_minimum_zero_based(
+ values: NDArray,
+ groups: NDArray,
+ number_groups: int,
+) -> NDArray:
+ result = np.full(number_groups, np.inf)
+ np.minimum.at(result, groups, values)
+ return result
+
+
+
+
+[docs]
+def _group_maximum_zero_based(
+ values: NDArray,
+ groups: NDArray,
+ number_groups: int,
+) -> NDArray:
+ result = np.full(number_groups, -np.inf)
+ np.maximum.at(result, groups, values)
+ return result
+
+
+
+
+[docs]
+def _group_sum_zero_based(
+ values: NDArray,
+ groups: NDArray,
+ number_groups: int,
+) -> NDArray:
+ return np.bincount(groups, weights=values, minlength=number_groups)
+
+
+
+
+[docs]
+def _read_satnum(
+ cfg: ConfigViaTOML, actnum: NDArray, nxyz: int, satnum_opm: NDArray
+) -> NDArray:
+ """Read or generate fine-grid SATNUM values.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration controlling the SATNUM source.
+ actnum
+ Fine-grid active-cell mask.
+ nxyz
+ Number of fine-grid cells.
+ satnum_opm
+ SATNUM values read from the reference INIT file.
+
+ Returns
+ -------
+ NDArray
+ SATNUM value for every fine-grid cell."""
+ reference_folder = (
+ Path(cfg.resource_directory) / "reference_simulation" / cfg.model_name
+ )
+ satnum = np.ones(nxyz, dtype=int)
+ if cfg.model_name == "norne":
+ satnum = np.load(reference_folder / "satnum.npy")
+ elif cfg.satnum_generation_method > 0:
+ satnum_files = {1: "satnum_5.out", 3: "satnum_60.out"}
+ satnum_values = []
+ with open(
+ reference_folder / satnum_files[cfg.satnum_generation_method],
+ "r",
+ encoding="utf8",
+ ) as file:
+ for row in csv.reader(file, delimiter="#"):
+ satnum_values.append(int(row[0]))
+ satnum = np.asarray(satnum_values)
+ else:
+ satnum[actnum] = satnum_opm
+ return satnum
+
+
+
+
+[docs]
+def coarsen_and_write_properties(cfg: ConfigViaTOML, coa_map: NDArray) -> int:
+ """Aggregate and write properties for a TOML-generated model.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration and reference-case settings.
+ coa_map
+ One-based fine-to-coarse cell mapping.
+
+ Returns
+ -------
+ int
+ Highest generated SATNUM value, used as the number of saturation tables."""
+ reference_folder = (
+ Path(cfg.resource_directory) / "reference_simulation" / cfg.model_name
+ )
+ case_path = (
+ Path(cfg.resource_directory)
+ / "reference_simulation"
+ / cfg.model_name
+ / cfg.reference_case_name
+ )
+ pycopm_info("coarsening and writing the static properties")
+ preprocessing_path = Path(cfg.output_directory) / "preprocessing"
+ num_cells = cfg.output_nx * cfg.output_ny * cfg.output_nz
+ num_dig = cfg.significant_digits
+ groups = np.asarray(coa_map) - 1
+ nxyz = cfg.original_cell_count
+ opm = OpmGrid(f"{case_path}.EGRID")
+ vol = np.asarray(opm.cellvolumes()) + 1e-10
+ vol_c = _group_sum_zero_based(vol, groups, num_cells)
+ opm = OpmFile(f"{case_path}.INIT")
+ porv = np.asarray(opm["PORV"])
+ actnum = porv > 0
+ actnum_c = _group_minimum_zero_based(actnum, groups, num_cells).astype(int)
+ active = actnum_c > 0
+ active_volumes = vol * actnum
+ props = ["poro", "ntg"]
+ for property_name in props:
+ values = np.zeros(nxyz, dtype=float)
+ values[actnum] = opm[property_name.upper()]
+ values_c = np.zeros(num_cells, dtype=float)
+ values_c[active] = (
+ _group_sum_zero_based(values * active_volumes, groups, num_cells)[active]
+ / vol_c[active]
+ )
+ write_compact_property_file(
+ preprocessing_path, property_name, values_c, num_dig
+ )
+
+ porv_c = np.zeros(num_cells, dtype=float)
+ porv_c[active] = _group_sum_zero_based(porv, groups, num_cells)[active]
+ if cfg.pore_volume_correction == 1:
+ coarse_pore_volume = np.sum(porv_c)
+ correction = np.sum(porv) / coarse_pore_volume
+ porv_c *= correction
+ write_compact_property_file(preprocessing_path, "porv", porv_c, num_dig)
+
+ props = ["fipnum", "eqlnum"]
+ if cfg.model_name == "norne":
+ props += ["fluxnum"]
+ else:
+ props += ["multnum", "pvtnum", "fipzon"]
+
+ for property_name in props:
+ values = np.zeros(nxyz, dtype=int)
+ values[actnum] = opm[property_name.upper()]
+ values_c = _group_minimum_zero_based(values, groups, num_cells).astype(int)
+ write_compact_property_file(
+ preprocessing_path, property_name, values_c, num_dig
+ )
+
+ satnum_c = np.ones(num_cells, dtype=int)
+ if cfg.model_name == "drogon" or (
+ cfg.saturation_function_method == 1 and cfg.satnum_generation_method in (1, 3)
+ ):
+ values = _read_satnum(cfg, actnum, nxyz, opm["SATNUM"])
+ satnum_c[:] = _group_minimum_zero_based(values, groups, num_cells).astype(int)
+
+ if cfg.saturation_function_method == 1 and cfg.satnum_generation_method == 2:
+ preceding_active_cells = np.concatenate(([0], np.cumsum(actnum_c[:-1])))
+ mask = preceding_active_cells > 1
+ satnum_c[mask] += preceding_active_cells[mask] - 1
+
+ write_compact_property_file(preprocessing_path, "satnum", satnum_c, num_dig)
+
+ endpoint_lines = []
+ for property_name in ("swl", "sgu", "swcr"):
+ values_c = np.zeros(num_cells, dtype=float)
+ for coarse_index in np.flatnonzero(active):
+ values = np.zeros(nxyz, dtype=float)
+ values[actnum] = opm[property_name.upper()]
+ fine_indices = np.flatnonzero(groups == coarse_index)
+ fine_pore_volume = porv[fine_indices]
+ coarse_pore_volume = np.sum(fine_pore_volume)
+ values_c[coarse_index] = (
+ np.sum(values[fine_indices] * fine_pore_volume) / coarse_pore_volume
+ )
+ endpoint_lines.append(f"{property_name.upper()}\n")
+ endpoint_lines.extend(
+ format_opm_compact_values(round_like_e(values_c, num_dig))
+ )
+ endpoint_lines.append("/\n")
+
+ write_include(preprocessing_path / "endpoints.inc", "".join(endpoint_lines))
+
+ if cfg.model_name == "norne":
+ values = np.load(reference_folder / "multz.npy")
+ multz_c = np.zeros(num_cells, dtype=float)
+ multz_minimum = _group_minimum_zero_based(values, groups, num_cells)
+ multz_c[active] = multz_minimum[active]
+ write_property(
+ preprocessing_path / "regionbarriers.inc", "MULTZ", multz_c, num_dig
+ )
+
+ active = actnum_c > 0
+ active_volumes = vol * actnum
+
+ project_path = Path(cfg.output_directory)
+ template_path = Path(cfg.resource_directory) / "template_scripts"
+ active_indices = np.flatnonzero(np.asarray(actnum_c) == 1)
+ last_active_index = int(active_indices[-1]) if active_indices.size else 0
+
+ for axis_index, property_name in enumerate(["permx", "permy", "permz"]):
+ values = np.zeros(nxyz, dtype=float)
+ values[actnum] = opm[property_name.upper()]
+ values_c = np.zeros(num_cells, dtype=float)
+
+ if cfg.rock_property_settings[axis_index][2] == "max":
+ maximum = _group_maximum_zero_based(values, groups, num_cells)
+ values_c[active] = maximum[active]
+ else:
+ weighted_sum = _group_sum_zero_based(
+ values * active_volumes, groups, num_cells
+ )
+ values_c[active] = weighted_sum[active] / vol_c[active]
+
+ write_compact_property_file(
+ preprocessing_path, property_name, values_c, num_dig
+ )
+
+ values_c_min_max = np.zeros((num_cells, 2), dtype=float)
+ minimum = np.full(num_cells, np.inf)
+ maximum = np.full(num_cells, -np.inf)
+ np.minimum.at(minimum, groups, values)
+ np.maximum.at(maximum, groups, values)
+ values_c_min_max[:, 0] = minimum
+ values_c_min_max[:, 1] = 1.1 * maximum
+
+ if cfg.rock_property_settings[axis_index][1] == 1 and cfg.execution_mode in (
+ "files",
+ "ert",
+ ):
+ property_name = cfg.rock_property_settings[axis_index][0]
+ variables = {
+ "rock_property_settings": cfg.rock_property_settings,
+ "execution_mode": cfg.execution_mode,
+ "last_active_index": last_active_index,
+ "values_c": values_c,
+ "values_c_min_max": values_c_min_max,
+ "i": axis_index,
+ "active": active,
+ }
+ _render_template(
+ template_path / "common" / "perm.mako",
+ project_path / "parameters" / f"{property_name}.tmpl",
+ **variables,
+ )
+ _render_template(
+ template_path / "common" / "perm_priors.mako",
+ project_path / "parameters" / f"{property_name}_priors.data",
+ **variables,
+ )
+ _render_template(
+ template_path / "common" / "perm_eval.mako",
+ project_path / "jobs" / f"{property_name}_eval.py",
+ **variables,
+ )
+
+ props = ["swat"]
+ if cfg.initialization_method != 0:
+ props += ["sgas", "pressure", "rs", "rv"]
+
+ swat = np.zeros(nxyz, dtype=float)
+ opm = OpmRestart(f"{case_path}.UNRST")
+
+ init_lines = []
+ for property_name in props:
+ values = np.zeros(nxyz, dtype=float)
+ values[actnum] = opm[property_name.upper(), 0]
+ if property_name == "swat":
+ swat = values.copy()
+
+ values_c = np.zeros(num_cells, dtype=float)
+ weighted_sum = _group_sum_zero_based(values * porv, groups, num_cells)
+ values_c[active] = weighted_sum[active] / porv_c[active]
+ init_lines.append(f"{property_name.upper()}\n")
+ init_lines.extend(format_opm_compact_values(round_like_e(values_c, num_dig)))
+ init_lines.append("/\n")
+ write_include(preprocessing_path / "init.inc", "".join(init_lines))
+
+ swatinit_c = np.zeros(num_cells, dtype=float)
+ swatinit_sum = _group_sum_zero_based(swat * porv, groups, num_cells)
+ swatinit_c[active] = swatinit_sum[active] / porv_c[active]
+
+ write_property(preprocessing_path / "swatinit.inc", "SWATINIT", swatinit_c, num_dig)
+
+ return int(np.max(satnum_c))
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0913,R0914,R0916,R0917
+
+"""Write OPM Flow decks, corner-point grids, properties, and ERT files."""
+
+import shlex
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+from mako.template import Template
+from numpy.typing import NDArray
+from opm.io.ecl import EclFile as OpmFile
+
+from pycopm.config.config import ConfigViaDeck, ConfigViaTOML
+
+HEADER = (
+ "-- Copyright (C) 2024-2026 NORCE Research AS\n"
+ "-- This deck was generated by pycopm https://github.com/cssr-tools/pycopm\n"
+)
+
+
+
+[docs]
+def format_opm_compact_values(v: NDArray) -> list[str]:
+ """Convert values to OPM repeated-value notation.
+
+ Parameters
+ ----------
+ v
+ One-dimensional values to compact.
+
+ Returns
+ -------
+ list[str]
+ Values formatted as ``n*value`` where consecutive values repeat."""
+ v = np.array(v)
+ change_idx = np.flatnonzero(np.diff(v, prepend=v[0] - 1))
+ counts = np.diff(np.append(change_idx, v.size))
+ vals = v[change_idx]
+
+ out = []
+ for val, n in zip(vals, counts):
+ val_str = str(int(val)) if float(val).is_integer() else str(val)
+ out.append(f"{n}*{val_str} " if n > 1 else f"{val_str} ")
+ return out
+
+
+
+
+[docs]
+def round_like_e(
+ values: NDArray | list,
+ significant_digits: int,
+) -> NDArray:
+ """Round values to a number of significant digits.
+
+ Parameters
+ ----------
+ values
+ Values to round.
+ significant_digits
+ Number of significant digits, or zero to preserve machine precision.
+
+ Returns
+ -------
+ NDArray
+ Rounded values with the original shape."""
+ if significant_digits == 0:
+ return np.asarray(values)
+ values = np.asarray(values)
+ decimal_digits = significant_digits - 1
+ rounded_values = np.fromiter(
+ (float(f"{value:.{decimal_digits}E}") for value in values.flat),
+ dtype=float,
+ count=values.size,
+ )
+ return rounded_values.reshape(values.shape)
+
+
+
+
+[docs]
+def _identify_removed_pillars(cfg: ConfigViaTOML) -> tuple[NDArray, NDArray]:
+ """Find COORD and ZCORN indices removed by TOML coarsening.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration containing axis coarsening arrays.
+
+ Returns
+ -------
+ coord_indices, zcorn_indices
+ Indices to remove from the reference grid arrays."""
+ mr: list[float] = []
+ ir: list[float] = []
+ nx_fine, ny_fine, nz_fine = cfg.original_nx, cfg.original_ny, cfg.original_nz
+ nxyz = cfg.original_cell_count
+ for column_index in range(nx_fine + 1):
+ if cfg.x_coarsening[column_index] > 1:
+ for pillar_index in range(
+ column_index, (nx_fine + 1) * (ny_fine + 1), nx_fine + 1
+ ):
+ mr.extend(range(pillar_index * 6, pillar_index * 6 + 6))
+ for zcorn_index in range(2 * column_index - 1, 8 * nxyz, 2 * nx_fine):
+ ir.extend([zcorn_index, zcorn_index + 1])
+ for row_index in range(ny_fine + 1):
+ if cfg.y_coarsening[row_index] > 1:
+ pillar_start = row_index * (nx_fine + 1)
+ pillar_stop = (row_index + 1) * (nx_fine + 1)
+ for pillar_index in range(pillar_start, pillar_stop):
+ mr.extend(range(pillar_index * 6, pillar_index * 6 + 6))
+ for zcorn_index in range(
+ (2 * row_index - 1) * 2 * nx_fine,
+ 8 * nxyz,
+ 4 * nx_fine * ny_fine,
+ ):
+ ir.extend(range(zcorn_index, zcorn_index + 4 * nx_fine))
+ layer_size = 4 * nx_fine * ny_fine
+ for layer_index in range(nz_fine + 1):
+ if cfg.z_coarsening[layer_index] > 1:
+ start_index = (2 * layer_index - 1) * layer_size
+ stop_index = (2 * layer_index + 1) * layer_size
+ ir.extend(range(start_index, stop_index))
+ return np.asarray(mr, dtype=int), np.asarray(ir, dtype=int)
+
+
+
+
+[docs]
+def _render_template(template_path: Path, output_path: Path, **variables: Any) -> None:
+ """Render a Mako template to a UTF-8 file.
+
+ Parameters
+ ----------
+ template_path
+ Source template path.
+ output_path
+ Destination path.
+ **variables
+ Values passed to the template."""
+ template = Template(filename=str(template_path))
+ output_path.write_text(template.render(**variables), encoding="utf8")
+
+
+
+
+[docs]
+def write_coarsened_model_files(cfg: ConfigViaTOML, number_tables: int) -> None:
+ """Write grid, deck, schedule, job, observation, and ERT files.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration for the generated project.
+ number_tables
+ Number of saturation-function tables to generate."""
+ if cfg.saturation_function_method > 0:
+ _write_let_tables(cfg, number_tables)
+ _write_grid_files(cfg)
+ _write_ert_files(cfg, number_tables)
+ project_path = Path(cfg.output_directory)
+ template_path = Path(cfg.resource_directory) / "template_scripts"
+ variables = {
+ "original_to_output_i": cfg.original_to_output_i,
+ "original_to_output_j": cfg.original_to_output_j,
+ "original_to_output_k": cfg.original_to_output_k,
+ }
+ _render_template(
+ template_path / cfg.model_name / "schedule.mako",
+ project_path / "preprocessing" / "schedule.SCH",
+ **variables,
+ )
+ _render_template(
+ template_path / "common" / "time_eval.mako",
+ project_path / "jobs" / "time_eval.py",
+ name=cfg.reference_case_name,
+ )
+ _render_template(
+ template_path / "common" / "flow_eval.mako",
+ project_path / "jobs" / "flow_eval.py",
+ flow=shlex.split(cfg.flow_command.strip())
+ + [f"{cfg.reference_case_name}_COARSER.DATA"],
+ )
+ _render_template(
+ template_path / cfg.model_name / "deck.mako",
+ project_path / "preprocessing" / f"{cfg.reference_case_name}_COARSER.DATA",
+ output_nx=cfg.output_nx,
+ output_ny=cfg.output_ny,
+ output_nz=cfg.output_nz,
+ output_directory=cfg.output_directory,
+ rock_property_settings=cfg.rock_property_settings,
+ execution_mode=cfg.execution_mode,
+ saturation_function_method=cfg.saturation_function_method,
+ use_let_tables=cfg.use_let_tables,
+ initial=cfg.initialization_method,
+ original_to_output_k=cfg.original_to_output_k,
+ number_tables=number_tables,
+ )
+
+
+
+
+[docs]
+def _write_ert_files(cfg: ConfigViaTOML, number_tables: int) -> None:
+ """Write the ERT configuration, parameter, job, and observation files."""
+ project_path = Path(cfg.output_directory)
+ template_path = Path(cfg.resource_directory) / "template_scripts"
+ for coefficient_index, coefficient in enumerate(cfg.let_parameters):
+ if (
+ coefficient[2] == 1
+ and cfg.execution_mode in ("files", "ert")
+ and cfg.saturation_function_method == 1
+ ):
+ cfg.use_let_tables = True
+ variables = {
+ "i": coefficient_index,
+ "number_tables": number_tables,
+ "let_parameters": cfg.let_parameters,
+ }
+ coefficient_name = str(coefficient[0])
+ _render_template(
+ template_path / "common" / "let.mako",
+ project_path / "parameters" / f"coeff_{coefficient_name}.tmpl",
+ **variables,
+ )
+ _render_template(
+ template_path / "common" / "let_priors.mako",
+ project_path / "parameters" / f"coeff_{coefficient_name}_priors.data",
+ **variables,
+ )
+ _render_template(
+ template_path / cfg.model_name / "observations.mako",
+ project_path / "observations" / "observations.data",
+ minerror=cfg.observation_minimum_errors,
+ error=cfg.observation_relative_errors,
+ )
+ variables = {
+ "let_parameters": cfg.let_parameters,
+ "execution_mode": cfg.execution_mode,
+ "number_tables": number_tables,
+ }
+ if cfg.use_let_tables:
+ _render_template(
+ template_path / cfg.model_name / "table_eval.mako",
+ project_path / "jobs" / "table_eval.py",
+ **variables,
+ )
+ _render_template(
+ template_path / "common" / "ert.mako",
+ project_path / "ert.ert",
+ execution_mode=cfg.execution_mode,
+ ensemble_size=cfg.ensemble_size,
+ min_successful_realizations=cfg.min_successful_realizations,
+ max_realization_runtime_seconds=cfg.max_realization_runtime_seconds,
+ max_parallel_realizations=cfg.max_parallel_realizations,
+ random_seed=cfg.random_seed,
+ reference_case_name=cfg.reference_case_name,
+ rock_property_settings=cfg.rock_property_settings,
+ use_let_tables=cfg.use_let_tables,
+ let_parameters=cfg.let_parameters,
+ resource_directory=cfg.resource_directory,
+ model_name=cfg.model_name,
+ number_tables=number_tables,
+ )
+
+
+
+
+[docs]
+def _write_let_tables(cfg: ConfigViaTOML, number_tables: int) -> None:
+ """Write the LET saturation-function tables."""
+ table_lines = [HEADER]
+
+ if cfg.model_name == "norne":
+ table_lines.append("SWOFLET\n")
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0.0001 {max(1.1, cfg.let_parameters[0][1])} "
+ f"{10.0 ** cfg.let_parameters[1][1]} {max(1.1, cfg.let_parameters[2][1])} "
+ "0.5 0 0 "
+ f"{max(1.0, cfg.let_parameters[12][1]) * max(1.1, cfg.let_parameters[3][1])} "
+ f"{10.0 ** cfg.let_parameters[4][1]} {max(1.1, cfg.let_parameters[5][1])} "
+ "1 0.69977 17.56167 0.95615 3.76138 0.03819 /\n"
+ )
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0.0001 {max(1.1, cfg.let_parameters[0][1])} "
+ f"{10.0 ** cfg.let_parameters[1][1]} {max(1.1, cfg.let_parameters[2][1])} "
+ f"0.5 0 0 {max(1.1, cfg.let_parameters[3][1])} "
+ f"{max(0.9, cfg.let_parameters[13][1]) * 10.0 ** cfg.let_parameters[4][1]} "
+ f"{max(1.0, cfg.let_parameters[14][1]) * max(1.1, cfg.let_parameters[5][1])} "
+ "1 0.69977 17.56167 0.95615 3.76138 0.03819 /\n"
+ )
+
+ table_lines.append("SGOFLET\n")
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0 {max(1.0, cfg.let_parameters[15][1]) * max(1.1, cfg.let_parameters[6][1])} "
+ f"{10.0 ** cfg.let_parameters[7][1]} {max(1.1, cfg.let_parameters[8][1])} "
+ f"0.95 0 0.0001 {max(1.1, cfg.let_parameters[9][1])} "
+ f"{10.0 ** cfg.let_parameters[10][1]} {max(1.1, cfg.let_parameters[11][1])} "
+ "0.99997432 1 1 1 0 0 /\n"
+ )
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0 {max(1.1, cfg.let_parameters[6][1])} "
+ f"{max(1.0, cfg.let_parameters[16][1]) * 10.0 ** cfg.let_parameters[7][1]} "
+ f"{max(1.0, cfg.let_parameters[17][1]) * max(1.1, cfg.let_parameters[8][1])} "
+ f"0.95 0 0.0001 {max(1.1, cfg.let_parameters[9][1])} "
+ f"{10.0 ** cfg.let_parameters[10][1]} {max(1.1, cfg.let_parameters[11][1])} "
+ "0.99997432 1 1 1 0 0 /\n"
+ )
+
+ elif cfg.model_name == "drogon":
+ table_lines.append("SWOFLET\n")
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0.0001 {max(1.1, cfg.let_parameters[0][1])} "
+ f"{10.0 ** cfg.let_parameters[1][1]} {max(1.1, cfg.let_parameters[2][1])} "
+ f"{cfg.let_parameters[14][1]} 0 0 "
+ f"{max(1.0, cfg.let_parameters[12][1]) * max(1.1, cfg.let_parameters[3][1])} "
+ f"{10.0 ** cfg.let_parameters[4][1]} {max(1.1, cfg.let_parameters[5][1])} "
+ f"{cfg.let_parameters[15][1]} 0.69977 17.56167 0.95615 3.76138 0.03819 /\n"
+ )
+
+ table_lines.append("SGOFLET\n")
+ for _ in range(number_tables):
+ table_lines.append(
+ f"0 0 {max(1.0, cfg.let_parameters[13][1]) * max(1.1, cfg.let_parameters[6][1])} "
+ f"{10.0 ** cfg.let_parameters[7][1]} {max(1.1, cfg.let_parameters[8][1])} "
+ f"{cfg.let_parameters[16][1]} 0 0.0001 {max(1.1, cfg.let_parameters[9][1])} "
+ f"{10.0 ** cfg.let_parameters[10][1]} {max(1.1, cfg.let_parameters[11][1])} "
+ f"{cfg.let_parameters[17][1]} 1 1 1 0 0 /\n"
+ )
+
+ (Path(cfg.output_directory) / "preprocessing" / "tables.inc").write_text(
+ "".join(table_lines), encoding="utf8"
+ )
+
+
+
+
+[docs]
+def _write_grid_files(cfg: ConfigViaTOML) -> None:
+ """Write the OPM grid-related files."""
+ case = (
+ Path(cfg.resource_directory)
+ / "reference_simulation"
+ / cfg.model_name
+ / cfg.reference_case_name
+ )
+ grid_path = f"{case}.EGRID"
+ grid_file = OpmFile(grid_path)
+ zc, cr = grid_file["ZCORN"], grid_file["COORD"]
+ coord_indices, zcorn_indices = _identify_removed_pillars(cfg)
+ cr = np.delete(np.asarray(cr), coord_indices)
+ zc = np.delete(np.asarray(zc), zcorn_indices)
+ write_grid(cfg, cr, zc, False)
+ project_path = Path(cfg.output_directory)
+ template_path = Path(cfg.resource_directory) / "template_scripts" / cfg.model_name
+ variables = {
+ "original_to_output_i": cfg.original_to_output_i,
+ "original_to_output_j": cfg.original_to_output_j,
+ "original_to_output_k": cfg.original_to_output_k,
+ }
+ _render_template(
+ template_path / "fault.mako",
+ project_path / "preprocessing" / "fault.inc",
+ **variables,
+ )
+ if cfg.model_name == "norne":
+ _render_template(
+ template_path / "localbarriers.mako",
+ project_path / "preprocessing" / "localbarriers.inc",
+ **variables,
+ )
+ elif cfg.model_name == "drogon":
+ _render_template(
+ template_path / "trans.mako",
+ project_path / "preprocessing" / "trans.inc",
+ **variables,
+ )
+
+
+
+
+[docs]
+def write_include(output_path: Path, text: str) -> None:
+ """Write text to an OPM include file with the pycopm header.
+
+ Parameters
+ ----------
+ output_path
+ Destination include path.
+ text
+ OPM deck text written after the header."""
+ output_path.write_text(f"{HEADER}{text}", encoding="utf8")
+
+
+
+
+[docs]
+def write_property(output_path: Path, keyword: str, values: NDArray, num_dig) -> None:
+ """Write one compact OPM property.
+
+ Parameters
+ ----------
+ output_path
+ Destination include path.
+ keyword
+ OPM property keyword.
+ values
+ Property values in global-cell order.
+ num_dig
+ Number of significant digits."""
+ compacted_values = format_opm_compact_values(
+ round_like_e(np.asarray(values), num_dig)
+ )
+ output_path.write_text(
+ f"{HEADER}{keyword}\n{''.join(compacted_values)}/\n", encoding="utf8"
+ )
+
+
+
+
+[docs]
+def write_compact_property_file(
+ preprocessing_path: Path, property_name: str, values: NDArray, num_dig
+) -> None:
+ """Write one compacted OPM property to a file."""
+ write_property(
+ preprocessing_path / f"{property_name}.inc",
+ property_name.upper(),
+ values,
+ num_dig,
+ )
+
+
+
+
+[docs]
+def write_porv(dck: ConfigViaDeck, modified_deck: list[str]) -> None:
+ """Write the OPM grid-related files."""
+ write_property_inc(
+ dck,
+ "porv",
+ dck.output_porv,
+ dck.output_porv.size,
+ modified_deck,
+ False,
+ )
+
+
+
+
+[docs]
+def write_property_inc(
+ dck: ConfigViaDeck,
+ property_name: str,
+ property_values: NDArray,
+ number_values: int,
+ modified_deck: list[str],
+ allow_inline: bool = False,
+ file_suffix: str = "",
+) -> bool:
+ """Write a property include or inline a constant property.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration controlling paths, prefixes, and precision.
+ property_name
+ OPM property name.
+ property_values
+ Values in output-grid order.
+ number_values
+ Expected number of output values.
+ modified_deck
+ Deck lines in which a constant property may be inlined.
+ allow_inline
+ Inline a property when all values are equal.
+ file_suffix
+ Optional suffix added before ``.INC``.
+
+ Returns
+ -------
+ bool
+ ``True`` when the property was inlined, otherwise ``False``."""
+ output_directory = Path(dck.output_directory)
+ num_dig = dck.significant_digits
+ property_values = np.asarray(property_values)
+ compacted_values = format_opm_compact_values(round_like_e(property_values, num_dig))
+ property_path = output_directory / (
+ f"{dck.include_prefix}{property_name.upper()}{file_suffix}.INC"
+ )
+ if (
+ allow_inline
+ and not file_suffix
+ and compacted_values
+ and len(compacted_values) == 1
+ and "*" in compacted_values[0]
+ and int(compacted_values[0].split("*", maxsplit=1)[0]) == number_values
+ ):
+ include_line = f"'{dck.include_prefix}{property_name.upper()}.INC' /\n"
+ include_index = modified_deck.index(include_line)
+ repeated_value = compacted_values[0].split("*", maxsplit=1)[1]
+ del modified_deck[include_index]
+ del modified_deck[include_index - 1]
+ modified_deck.insert(
+ include_index - 1,
+ f"{property_name.upper()}\n{number_values}*{repeated_value}/\n",
+ )
+ property_path.unlink(missing_ok=True)
+ return True
+ property_path.write_text(
+ HEADER + f"{property_name.upper()}\n{''.join(compacted_values)}/\n",
+ encoding="utf8",
+ )
+ return False
+
+
+
+
+[docs]
+def write_grid(
+ cfg: ConfigViaDeck | ConfigViaTOML, cr: NDArray, zc: NDArray, dual: bool
+) -> None:
+ """Write a corner-point grid in GRDECL syntax.
+
+ Parameters
+ ----------
+ cfg
+ Deck or TOML configuration containing output dimensions and precision.
+ cr, zc
+ Flattened ``COORD`` and ``ZCORN`` arrays.
+ dual
+ Extend the j dimension for a dual-porosity grid."""
+ if isinstance(cfg, ConfigViaDeck):
+ cfg.output_cell_count = cfg.output_nx * cfg.output_ny * cfg.output_nz
+ output_grid = [
+ HEADER,
+ "SPECGRID\n",
+ f"{cfg.output_nx} {cfg.output_ny * (int(dual) + 1) + int(dual)} {cfg.output_nz} /\n",
+ "COORD\n",
+ ]
+ num_dig = cfg.significant_digits
+ output_grid.extend(format_opm_compact_values(round_like_e(np.asarray(cr), num_dig)))
+ output_grid.extend(["/\n", "ZCORN\n"])
+ output_grid.extend(format_opm_compact_values(round_like_e(np.asarray(zc), num_dig)))
+ output_grid.append("/")
+ if isinstance(cfg, ConfigViaDeck):
+ if not cfg.dual_porosity_criterion and not cfg.grid_transformation:
+ output_grid.append("\nACTNUM\n")
+ output_grid.extend(
+ format_opm_compact_values(
+ round_like_e(np.asarray(cfg.output_actnum), num_dig)
+ )
+ )
+ output_grid.append("/")
+ output_grid.append("\n")
+ output_path = Path(cfg.output_directory) / f"{cfg.include_prefix}GRID.INC"
+ else:
+ output_grid.append("\n")
+ output_path = (
+ Path(cfg.output_directory)
+ / "preprocessing"
+ / f"{cfg.reference_case_name}_COARSER.GRDECL"
+ )
+ output_path.write_text("".join(output_grid), encoding="utf8")
+
+
+
+
+[docs]
+def write_reference_to_coarse_map(dck: ConfigViaDeck, reftocoa: NDArray) -> None:
+ """Write the reference-to-coarse mapping as OPERNUM.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration controlling the output path and precision.
+ reftocoa
+ Coarse-cell identifier for every reference-grid cell."""
+ num_dig = dck.significant_digits
+ output_directory = Path(dck.output_directory)
+ opernum_output = format_opm_compact_values(round_like_e(reftocoa, num_dig))
+ opernum_path = (
+ output_directory / f"{dck.original_deck_name}_OPERNUM_PYCOPM_REFTOCOA.INC"
+ )
+ opernum_path.write_text(
+ f"{HEADER}OPERNUM\n{''.join(opernum_output)}/\n", encoding="utf8"
+ )
+
+
+
+
+[docs]
+def write_dual_properties(
+ dck: ConfigViaDeck,
+ coarsening,
+ number_values: int,
+ modified_deck: list[str],
+) -> None:
+ """Finalize property files for a dual-porosity grid.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration and generated property names.
+ coarsening
+ Coarsening data containing matrix and fracture transmissibilities.
+ number_values
+ Number of cells in the extended dual grid.
+ modified_deck
+ Deck lines that may receive inlined properties."""
+ names = (
+ dck.props_keywords
+ + dck.grids_keywords
+ + dck.regions_keywords
+ + dck.solution_keywords
+ + ["porv"]
+ )
+ output_directory = Path(dck.output_directory)
+ for property_name in names:
+ property_path = output_directory / (
+ f"{dck.include_prefix}{property_name.upper()}.INC"
+ )
+ if property_name not in ["tranx", "trany"]:
+ temporary_property_path = output_directory / (
+ f"{dck.include_prefix}{property_name.upper()}_DUAL_TMP_PYCOPM.INC"
+ )
+ if temporary_property_path.is_file():
+ temporary_property_path.replace(property_path)
+ continue
+ property_values = (
+ coarsening.coarse_tranx
+ if property_name == "tranx"
+ else coarsening.coarse_trany
+ )
+ write_property_inc(
+ dck,
+ property_name,
+ property_values,
+ number_values,
+ modified_deck,
+ True,
+ )
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,R0801,E1102
+
+"""Coordinate coarsening, refinement, submodel extraction, and grid transformations."""
+
+import argparse
+import csv
+import subprocess
+import sys
+from pathlib import Path
+from shutil import copy2
+from typing import cast
+
+import numpy as np
+from opm.io.ecl import EclFile as OpmFile
+from opm.io.ecl import EGrid as OpmGrid
+
+from pycopm.config.config import ConfigViaDeck
+from pycopm.utils.coarsening import (
+ CoarseningMaps,
+ build_dual_porosity_grid,
+ coarsen_corner_point_grid,
+ coarsen_properties,
+ create_coarsening_maps,
+ map_nnc_transmissibilities,
+ redistribute_removed_pore_volume,
+)
+from pycopm.utils.files_writer import (
+ write_dual_properties,
+ write_grid,
+ write_include,
+ write_porv,
+)
+from pycopm.utils.parser_deck import find_multiplier_keywords, process_deck
+from pycopm.utils.refinement import (
+ RefinementMaps,
+ create_refinement_maps,
+ refine_grid,
+ refine_properties,
+)
+from pycopm.utils.terminal import (
+ cli_correct_value,
+ cli_error_value,
+ cli_info_value,
+ pycopm_error,
+ pycopm_info,
+ pycopm_success,
+ pycopm_warning,
+)
+from pycopm.utils.transformation import (
+ transform_grid,
+ transform_properties,
+)
+from pycopm.utils.vicinity import (
+ VicinityMaps,
+ apply_boundary_pore_volume_correction,
+ create_vicinity_maps,
+ extract_vicinity_grid,
+ map_vicinity_properties,
+)
+
+
+
+[docs]
+def create_deck(dck: ConfigViaDeck, cmdargs: argparse.Namespace) -> None:
+ """Generate a modified OPM deck and its include files.
+
+ The selected workflow can preprocess the input deck, coarsen or refine the
+ grid, extract a vicinity submodel, transform coordinates, and optionally run
+ a validation dry run.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration populated from command-line arguments.
+ cmdargs
+ Parsed command arguments used to build coarsening or refinement maps."""
+ output_directory = Path(dck.output_directory)
+ source_deck = Path(f"{dck.input_deck_path}.DATA")
+ generated_files = []
+ if dck.requested_ijk[0]:
+ dck.requested_ijk = [int(value) for value in dck.requested_ijk[0].split(",")]
+ if not dck.output_deck_name:
+ dck.output_deck_name = f"{dck.input_deck_name}_PYCOPM"
+ flags_dry_run = (
+ "--parsing-strictness=low --check-satfunc-consistency=false "
+ "--enable-dry-run=true --output-mode=none"
+ )
+ output_types = [".INIT", ".EGRID"]
+ dry_run_name = f"{dck.input_deck_name}_PREP_PYCOPM_DRYRUN"
+ dry_run_deck = output_directory / f"{dry_run_name}.DATA"
+ if dck.execution_mode in ("prep", "prep_deck", "all"):
+ if dck.write_explicit_solution:
+ output_types.append(".UNRST")
+ modified_deck = []
+ with source_deck.open("r", encoding="utf8") as file_handle:
+ for csv_row in csv.reader(file_handle):
+ deck_line = str(csv_row)[2:-2].strip()
+ if deck_line == "SCHEDULE":
+ modified_deck.append(deck_line)
+ modified_deck.append("RPTRST\n'BASIC=2'/\n")
+ modified_deck.append("TSTEP\n1*0.0001/\n")
+ break
+ modified_deck.append(deck_line)
+ dry_run_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in modified_deck),
+ encoding="utf8",
+ )
+ pycopm_info(
+ f"temporary {cli_info_value(dry_run_deck.name)} created from "
+ f"{cli_info_value(str(source_deck))} for the initial run that generates "
+ "the grid (.EGRID), static (.INIT), and initial (.UNRST) properties."
+ )
+ subprocess.run(
+ [
+ dck.flow_command,
+ dry_run_deck.name,
+ "--output-mode=none",
+ "--parsing-strictness=low",
+ "--enable-opm-rst-file=1",
+ ],
+ cwd=output_directory,
+ check=True,
+ )
+ dry_run_deck.unlink(missing_ok=True)
+ copy2(source_deck, dry_run_deck)
+ pycopm_info(
+ f"cloning {cli_info_value(str(source_deck))} to "
+ f"{cli_info_value(dry_run_deck.name)}."
+ )
+ else:
+ copy2(source_deck, dry_run_deck)
+ pycopm_info(
+ f"cloning {cli_info_value(str(source_deck))} to "
+ f"{cli_info_value(dry_run_deck.name)} for the initial dry run that "
+ "generates the grid (.EGRID) and static (.INIT) properties."
+ )
+ subprocess.run(
+ [dck.flow_command, dry_run_deck.name, *flags_dry_run.split()],
+ cwd=output_directory,
+ check=True,
+ )
+ for output_type in output_types:
+ output_file = output_directory / f"{dry_run_name}{output_type}"
+ if not output_file.is_file():
+ if output_type == ".INIT":
+ pycopm_error(
+ f"required file {cli_error_value(str(output_file))} was not found; "
+ f"add {cli_correct_value('INIT')} to the GRID section of "
+ f"{cli_error_value(f'{dck.input_deck_name}.DATA')}."
+ )
+ elif output_type == ".EGRID":
+ pycopm_error(
+ f"required file {cli_error_value(str(output_file))} was not found; "
+ f"remove {cli_error_value('GRIDFILE')} from the GRID section of "
+ f"{cli_error_value(f'{dck.input_deck_name}.DATA')}."
+ )
+ else:
+ pycopm_error(
+ f"required file {cli_error_value(str(output_file))} was not found; "
+ f"check input deck {cli_error_value(f'{dck.input_deck_name}.DATA')}."
+ )
+ pycopm_success(
+ f"initial dry run succeeded; 3 files ({dry_run_name}.DATA, .EGRID, and .INIT)\n"
+ " written to ",
+ str(output_directory),
+ [],
+ )
+ if dck.execution_mode in ("prep_deck", "deck", "deck_dry", "all"):
+ dck.original_deck_name = dck.input_deck_name
+ dck.input_deck_name = str(output_directory / dry_run_name)
+ for output_type in output_types:
+ output_file = Path(f"{dck.input_deck_name}{output_type}")
+ if not output_file.is_file():
+ pycopm_error(
+ f"required file {cli_error_value(str(output_file))} was not found; "
+ f"run pycopm with {cli_correct_value('-m prep_deck')} and without "
+ f"{cli_error_value('-ijk')}."
+ )
+ dck.props_keywords = ["permx", "permy", "permz", "poro"]
+ dck.base_keywords = dck.props_keywords + ["grid"]
+ if dck.refinement_enabled:
+ pycopm_info("generating the refined files, please wait...")
+ elif dck.vicinity_specification:
+ pycopm_info("generating the submodel files, please wait...")
+ elif dck.grid_transformation:
+ pycopm_info("generating the transformed files, please wait...")
+ else:
+ pycopm_info("generating the coarsened files, please wait...")
+ _initialize_deck_data(dck)
+ dck.original_cell_count = dck.original_nx * dck.original_ny * dck.original_nz
+ if dck.transmissibility_coarsening_method > 0:
+ dck.props_keywords.extend(("tranx", "trany", "tranz"))
+ dck.original_active_cell_mask = dck.original_porv > 0
+ vicinity: VicinityMaps = cast(VicinityMaps, None)
+ refinement: RefinementMaps = cast(RefinementMaps, None)
+ coarsening: CoarseningMaps = cast(CoarseningMaps, None)
+ if dck.refinement_enabled:
+ refinement = create_refinement_maps(dck, cmdargs)
+ elif dck.vicinity_specification:
+ vicinity = create_vicinity_maps(dck)
+ elif dck.coarsening_enabled:
+ coarsening = create_coarsening_maps(dck, cmdargs)
+ if not dck.grid_transformation:
+ _create_index_mappings(dck, vicinity, refinement, coarsening)
+ if dck.requested_ijk[0]:
+ pycopm_success(
+ f"mapped indices: "
+ f"{cli_info_value(str(dck.original_to_output_i[dck.requested_ijk[0]]))}, "
+ f"{cli_info_value(str(dck.original_to_output_j[dck.requested_ijk[1]]))}, "
+ f"{cli_info_value(str(dck.original_to_output_k[dck.requested_ijk[2]]))}",
+ "",
+ [],
+ )
+ sys.exit(0)
+ modified_deck, wellcind = process_deck(dck, vicinity)
+ pycopm_info("processing the mappings")
+ cr, zc = np.array([]), np.array([])
+ if dck.grid_transformation:
+ generated_files.extend(transform_properties(dck, modified_deck))
+ transform_grid(dck)
+ elif dck.refinement_enabled:
+ generated_files.extend(refine_properties(dck, refinement, modified_deck))
+ refine_grid(dck, refinement)
+ elif dck.vicinity_specification:
+ generated_files.extend(
+ map_vicinity_properties(dck, vicinity, modified_deck)
+ )
+ extract_vicinity_grid(dck, vicinity)
+ apply_boundary_pore_volume_correction(dck, vicinity)
+ write_porv(dck, modified_deck)
+ else:
+ cluster_minimum, cluster_maximum, removed_cells, file_names = (
+ coarsen_properties(
+ dck,
+ coarsening,
+ modified_deck,
+ wellcind,
+ )
+ )
+ generated_files.extend(file_names)
+ if dck.pore_volume_correction == 1:
+ redistribute_removed_pore_volume(
+ dck,
+ coarsening.cell_groups,
+ cluster_minimum,
+ cluster_maximum,
+ removed_cells,
+ )
+ write_porv(dck, modified_deck)
+ generated_files.append(f"{dck.include_prefix}PORV.INC")
+ cr, zc = coarsen_corner_point_grid(dck, coarsening)
+ generated_files.append(f"{dck.include_prefix}GRID.INC")
+ generated_deck = output_directory / f"{dck.output_deck_name}.DATA"
+ generated_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in modified_deck),
+ encoding="utf8",
+ )
+ if dck.correct_fluid_in_place == 1:
+ _correct_fluid_in_place(dck, modified_deck)
+ if (
+ dck.coarsening_enabled
+ and dck.egrid_file.count("NNC1")
+ and dck.transmissibility_coarsening_method > 0
+ ):
+ pycopm_info(
+ "calling OPM Flow for a dry run of the generated model, "
+ "needed for the NNC transmissibilities"
+ )
+ subprocess.run(
+ [dck.flow_command, generated_deck.name, *flags_dry_run.split()],
+ cwd=output_directory,
+ check=False,
+ )
+ generated_grid = output_directory / f"{dck.output_deck_name}.EGRID"
+ if (
+ OpmFile(str(generated_grid)).count("NNC1")
+ or OpmFile(f"{dck.input_deck_name}.EGRID").count("NNC1")
+ ) and dck.transmissibility_coarsening_method > 0:
+ generated_files.extend(map_nnc_transmissibilities(dck, coarsening))
+ else:
+ pycopm_warning("no NNC transmissibilities were found.")
+ generated_grid.unlink()
+ tmp = output_directory / f"{dck.output_deck_name}.INIT"
+ tmp.unlink()
+ if dck.coarsening_enabled:
+ if dck.dual_porosity_criterion:
+ cr, zc = build_dual_porosity_grid(dck, coarsening, cr, zc)
+ write_grid(dck, cr, zc, True)
+ write_dual_properties(
+ dck,
+ coarsening,
+ dck.output_nx * (2 * dck.output_ny + 1) * dck.output_nz,
+ modified_deck,
+ )
+ grid_include_index = modified_deck.index(
+ f"'{dck.include_prefix}GRID.INC' /\n"
+ )
+ modified_deck.insert(
+ grid_include_index + 1,
+ f"INCLUDE\n'{dck.include_prefix}NNC.INC' /\n",
+ )
+ dimension_index = modified_deck.index(
+ f"{dck.output_nx} {dck.output_ny} {dck.output_nz} /"
+ )
+ dual_ny = 2 * dck.output_ny + 1
+ modified_deck[dimension_index] = (
+ f"{dck.output_nx} {dual_ny} {dck.output_nz} /"
+ )
+ generated_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in modified_deck),
+ encoding="utf8",
+ )
+ coarsening.nnc_text += "/\n"
+ write_include(
+ output_directory / f"{dck.include_prefix}NNC.INC",
+ "".join(coarsening.nnc_text),
+ )
+ generated_files.append(f"{dck.include_prefix}NNC.INC")
+ elif coarsening.nnc_text != "NNC\n":
+ grid_include_index = modified_deck.index(
+ f"'{dck.include_prefix}GRID.INC' /\n"
+ )
+ modified_deck.insert(
+ grid_include_index + 1,
+ f"INCLUDE\n'{dck.include_prefix}NNC.INC' /\n",
+ )
+ generated_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in modified_deck),
+ encoding="utf8",
+ )
+ coarsening.nnc_text += "/\n"
+ write_include(
+ output_directory / f"{dck.include_prefix}NNC.INC",
+ "".join(coarsening.nnc_text),
+ )
+ generated_files.append(f"{dck.include_prefix}NNC.INC")
+ generated_files.append(f"{dck.output_deck_name}.DATA")
+ pycopm_success(
+ "",
+ str(output_directory),
+ sorted(set(generated_files)),
+ )
+ if dck.execution_mode in ("deck_dry", "dry", "all"):
+ pycopm_info("calling OPM Flow for a dry run of the generated model.")
+ completed_process = subprocess.run(
+ [dck.flow_command, f"{dck.output_deck_name}.DATA", *flags_dry_run.split()],
+ cwd=output_directory,
+ check=False,
+ )
+ if completed_process.returncode != 0:
+ pycopm_error(
+ "the dry run of "
+ f"{cli_error_value(str(output_directory / (dck.output_deck_name + '.DATA')))} "
+ "failed. Check the OPM Flow output in the terminal. Correct the input "
+ f"deck {cli_error_value(str(source_deck))} or the generated deck; otherwise, "
+ "raise an issue at https://github.com/cssr-tools/pycopm/issues."
+ )
+ else:
+ pycopm_success(
+ "dry-run results of the generated deck by pycopm were written to ",
+ str(output_directory),
+ [],
+ )
+
+
+
+
+[docs]
+def _correct_fluid_in_place(dck: ConfigViaDeck, modified_deck: list[str]) -> None:
+ """Adjust output pore volume to match input oil and gas in place.
+
+ Short Flow runs provide the fluid-in-place values used for two successive pore
+ volume corrections.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose ``output_porv`` is updated.
+ modified_deck
+ Generated deck lines used to create the correction case."""
+ output_directory = Path(dck.output_directory)
+ flags_one_step = (
+ "--parsing-strictness=low --check-satfunc-consistency=false "
+ "--output-mode=none --solver-max-restarts=20 "
+ "--solver-continue-on-convergence-failure=true "
+ f"--output-dir={output_directory}"
+ )
+ threshold = 1e-1
+ deck_file = Path(f"{dck.input_deck_name}.DATA")
+ deck_lines = deck_file.read_text(encoding="utf8").splitlines()
+ schedule_index = deck_lines.index("SCHEDULE")
+ deck_lines = deck_lines[: schedule_index + 1] + [
+ "TSTEP",
+ "0.01 /",
+ ]
+ restart_index = deck_lines.index("RPTRST")
+ restart_options = deck_lines[restart_index + 1].split("/")[0]
+ deck_lines[restart_index + 1] = f"{restart_options} FIP /"
+ one_step_deck = output_directory / f"{dck.output_deck_name}_1STEP.DATA"
+ correction_deck = output_directory / f"{dck.output_deck_name}_CORR.DATA"
+ one_step_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in deck_lines),
+ encoding="utf8",
+ )
+ schedule_index = modified_deck.index("SCHEDULE")
+ deckcorr = modified_deck[: schedule_index + 1] + [
+ "TSTEP",
+ "0.01 /",
+ ]
+ restart_index = deckcorr.index("RPTRST")
+ restart_options = deckcorr[restart_index + 1].split("/")[0]
+ deckcorr[restart_index + 1] = f"{restart_options} FIP /"
+ correction_deck.write_text(
+ "".join(f"{deck_line}\n" for deck_line in deckcorr),
+ encoding="utf8",
+ )
+ pycopm_info(
+ f"running {cli_info_value(str(one_step_deck))} and "
+ f"{cli_info_value(str(correction_deck))} to correct the pore volume."
+ )
+ subprocess.run(
+ [dck.flow_command, str(correction_deck), *flags_one_step.split()],
+ check=False,
+ )
+ subprocess.run(
+ [dck.flow_command, str(one_step_deck), *flags_one_step.split()],
+ check=False,
+ )
+ reference_restart = OpmFile(
+ str(output_directory / f"{dck.output_deck_name}_1STEP.UNRST")
+ )
+ corrected_restart = OpmFile(
+ str(output_directory / f"{dck.output_deck_name}_CORR.UNRST")
+ )
+ corrected_init = OpmFile(
+ str(output_directory / f"{dck.output_deck_name}_CORR.INIT")
+ )
+ reference_fip_gas = np.asarray(reference_restart["FIPGAS", 0])
+ reference_fip_oil = np.asarray(reference_restart["FIPOIL", 0])
+ corrected_porv = np.asarray(corrected_init["PORV"])
+ corrected_fip_gas = np.asarray(corrected_restart["FIPGAS", 0])
+ corrected_fip_oil = np.asarray(corrected_restart["FIPOIL", 0])
+ active_porv = corrected_porv[corrected_porv > 0]
+ correction_factor = np.sum(reference_fip_oil) / np.sum(corrected_fip_oil) - 1
+ low_oil_cells = corrected_fip_oil <= threshold
+ high_oil_cells = corrected_fip_oil > threshold
+ active_porv[low_oil_cells] -= (
+ correction_factor
+ * np.sum(active_porv[high_oil_cells])
+ / np.count_nonzero(low_oil_cells)
+ )
+ active_porv[high_oil_cells] *= 1 + correction_factor
+ corrected_porv[corrected_porv > 0] = active_porv
+ corrected_porv[np.isnan(corrected_porv)] = 0
+ dck.output_porv = corrected_porv
+ write_porv(dck, modified_deck)
+ subprocess.run(
+ [dck.flow_command, str(correction_deck), *flags_one_step.split()],
+ check=False,
+ )
+ corrected_restart = OpmFile(
+ str(output_directory / f"{dck.output_deck_name}_CORR.UNRST")
+ )
+ corrected_init = OpmFile(
+ str(output_directory / f"{dck.output_deck_name}_CORR.INIT")
+ )
+ corrected_porv = np.asarray(corrected_init["PORV"])
+ corrected_fip_gas = np.asarray(corrected_restart["FIPGAS", 0])
+ corrected_fip_oil = np.asarray(corrected_restart["FIPOIL", 0])
+ corrected_sgas = np.asarray(corrected_restart["SGAS", 0])
+ active_porv = corrected_porv[corrected_porv > 0]
+ high_gas_cells = corrected_sgas > threshold
+ low_oil_cells = corrected_fip_oil <= threshold
+ correction_factor = (
+ np.sum(reference_fip_gas) - np.sum(corrected_fip_gas)
+ ) / np.sum(corrected_fip_gas[high_gas_cells])
+ active_porv[low_oil_cells] -= (
+ correction_factor
+ * np.sum(active_porv[high_gas_cells])
+ / np.count_nonzero(low_oil_cells)
+ )
+ active_porv[high_gas_cells] *= 1 + correction_factor
+ corrected_porv[corrected_porv > 0] = active_porv
+ corrected_porv[np.isnan(corrected_porv)] = 0
+ dck.output_porv = corrected_porv
+ write_porv(dck, modified_deck)
+ pycopm_info(
+ f"running {cli_info_value(str(correction_deck))} with the corrected pore volume."
+ )
+ subprocess.run(
+ [dck.flow_command, str(correction_deck), *flags_one_step.split()],
+ check=False,
+ )
+
+
+
+
+[docs]
+def _create_index_mappings(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ refinement: RefinementMaps,
+ coarsening: CoarseningMaps,
+) -> None:
+ """Create original-to-output mappings for each grid axis.
+
+ Depending on the selected workflow, the mappings represent coarse cells,
+ submodel indices, or the first and last cells created by refinement.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration updated with the index mappings.
+ vicinity
+ Vicinity bounds for submodel extraction.
+ refinement
+ Per-axis refinement values.
+ coarsening
+ Per-axis coarsening values."""
+ if dck.refinement_enabled:
+ for index_name, direction, ref_dir in (
+ ("i", "x", refinement.x),
+ ("j", "y", refinement.y),
+ ("k", "z", refinement.z),
+ ):
+ original_size = getattr(dck, f"original_n{direction}")
+ setattr(
+ dck,
+ f"original_to_output_{index_name}",
+ np.zeros(original_size + 1, dtype=int),
+ )
+ setattr(
+ dck,
+ f"original_to_first_refined_{index_name}",
+ np.zeros(original_size + 1, dtype=int),
+ )
+ setattr(
+ dck,
+ f"original_to_last_refined_{index_name}",
+ np.zeros(original_size + 1, dtype=int),
+ )
+ next_index = 2
+ for original_index in range(
+ getattr(dck, f"original_to_output_{index_name}").size - 1
+ ):
+ midpoint_count = 1
+ refinement_factor = int(ref_dir[original_index])
+ for refinement_index in range(refinement_factor):
+ next_index += 1
+ if refinement_index % 2 == 0:
+ midpoint_count += 1
+ getattr(dck, f"original_to_output_{index_name}")[original_index + 1] = (
+ next_index - midpoint_count
+ )
+ getattr(dck, f"original_to_first_refined_{index_name}")[
+ original_index + 1
+ ] = (
+ next_index - 2 * (midpoint_count - 1) - (refinement_factor + 1) % 2
+ )
+ value = next_index - 1
+ getattr(dck, f"original_to_last_refined_{index_name}")[
+ original_index + 1
+ ] = value
+ next_index += 1
+ elif dck.vicinity_specification:
+ for index_name, dimension_name, minimum_index, maximum_index in (
+ ("i", "x", vicinity.min_i, vicinity.max_i),
+ ("j", "y", vicinity.min_j, vicinity.max_j),
+ ("k", "z", vicinity.min_k, vicinity.max_k),
+ ):
+ original_size = getattr(dck, f"original_n{dimension_name}")
+ setattr(
+ dck,
+ f"original_to_output_{index_name}",
+ np.zeros(original_size + 1, dtype=int),
+ )
+ mapped_index = 1
+ original_size = getattr(dck, f"original_to_output_{index_name}").size - 1
+ for original_index in range(original_size):
+ if minimum_index <= original_index + 1 <= maximum_index:
+ getattr(dck, f"original_to_output_{index_name}")[
+ original_index + 1
+ ] = mapped_index
+ mapped_index += 1
+ setattr(dck, f"output_n{dimension_name}", mapped_index - 1)
+ else:
+ for index_name, direction, coa_dir in (
+ ("i", "x", coarsening.x),
+ ("j", "y", coarsening.y),
+ ("k", "z", coarsening.z),
+ ):
+ original_size = getattr(dck, f"original_n{direction}")
+ setattr(
+ dck,
+ f"original_to_output_{index_name}",
+ np.zeros(original_size + 1, dtype=int),
+ )
+ mapped_index = 1
+ for original_index in range(getattr(dck, f"original_n{direction}")):
+ source_index = original_index + 1
+ if getattr(dck, f"original_to_output_{index_name}")[source_index] == 0:
+ getattr(dck, f"original_to_output_{index_name}")[
+ source_index
+ ] = mapped_index
+ mapped_index += 1
+ if coa_dir[source_index] > 1:
+ getattr(dck, f"original_to_output_{index_name}")[
+ source_index + 1
+ ] = getattr(dck, f"original_to_output_{index_name}")[source_index]
+
+
+
+
+[docs]
+def _initialize_deck_data(dck: ConfigViaDeck) -> None:
+ """Load dry-run grid and property data into the deck configuration.
+
+ The function opens EGRID, INIT, and optional restart files, determines grid
+ dimensions, and collects available property keywords.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration updated with OPM files, dimensions, and keyword lists."""
+ special_properties = [
+ "swatinit",
+ "sowcr",
+ "sogcr",
+ "swcr",
+ "sgu",
+ "swl",
+ "krwr",
+ "krw",
+ "krorw",
+ "krorg",
+ "kro",
+ "krgr",
+ "krg",
+ ]
+ dck.egrid_file = OpmFile(f"{dck.input_deck_name}.EGRID")
+ dck.grid_model = OpmGrid(f"{dck.input_deck_name}.EGRID")
+ dck.init_file = OpmFile(f"{dck.input_deck_name}.INIT")
+ for property_name in special_properties:
+ if dck.init_file.count(property_name.upper()):
+ dck.props_keywords.append(property_name)
+ dck.special_keywords.append(property_name)
+ multipliers_names = ["multx", "multx-", "multy", "multy-", "multz", "multz-"]
+ maindeckmultflt, multipliers_values = find_multiplier_keywords(dck)
+ for mlt_val, mlt_name in zip(multipliers_values, multipliers_names):
+ keyword = mlt_name.upper()
+ if dck.init_file.count(keyword):
+ multiplier_deck = np.asarray(dck.init_file[keyword])
+ if np.any(multiplier_deck != 1) and (mlt_val or not maindeckmultflt):
+ dck.props_keywords.append(mlt_name)
+ dck.multipliers_keywords.append(mlt_name)
+ for property_name in ("multnum", "fluxnum"):
+ keyword = property_name.upper()
+ if dck.init_file.count(keyword):
+ property_values = np.asarray(dck.init_file[keyword])
+ if np.any(property_values != 1):
+ dck.grids_keywords.append(property_name)
+ for property_name in ("thconr", "disperc"):
+ if dck.init_file.count(property_name.upper()):
+ dck.grids_keywords.append(property_name)
+ for property_name in (
+ "endnum",
+ "eqlnum",
+ "fipnum",
+ "imbnum",
+ "miscnum",
+ "opernum",
+ "pvtnum",
+ "rocknum",
+ "satnum",
+ ):
+ keyword = property_name.upper()
+ if dck.init_file.count(keyword):
+ property_values = np.asarray(dck.init_file[keyword])
+ if np.any(property_values != 1):
+ dck.regions_keywords.append(property_name)
+ dck.original_nx, dck.original_ny, dck.original_nz = dck.grid_model.dimension
+ dck.output_nx, dck.output_ny, dck.output_nz = dck.grid_model.dimension
+ dck.original_porv = np.asarray(dck.init_file["PORV"])
+ if dck.write_explicit_solution:
+ dck.restart_file = OpmFile(f"{dck.input_deck_name}.UNRST")
+ for property_name in (
+ "sgas",
+ "soil",
+ "swat",
+ "rs",
+ "rv",
+ "rsw",
+ "rvw",
+ "pressure",
+ "sbiof",
+ "scalc",
+ "smicr",
+ "soxyg",
+ "surea",
+ "ssol",
+ "spoly",
+ "surf",
+ "saltp",
+ "salt",
+ ):
+ if dck.restart_file.count(property_name.upper()):
+ dck.solution_keywords.append(property_name)
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0912,R0913,R0914,R0915,R0917,R1702
+"""Create configuration objects from command-line arguments and TOML files."""
+
+import argparse
+import datetime as dt
+import math
+import tomllib
+from pathlib import Path
+from typing import Any, TypeGuard
+
+import numpy as np
+from numpy.typing import NDArray
+from opm.io.ecl import EGrid as OpmGrid
+
+from pycopm.config.config import ConfigViaDeck, ConfigViaTOML
+from pycopm.utils.terminal import (
+ cli_correct_value,
+ cli_error_value,
+ cli_warning_value,
+ pycopm_error,
+ pycopm_warning,
+)
+
+TOML_KEYS = {
+ "flow_command",
+ "model_name",
+ "execution_mode",
+ "ensemble_size",
+ "max_parallel_realizations",
+ "max_realization_runtime_seconds",
+ "min_successful_realizations",
+ "random_seed",
+ "saturation_function_method",
+ "pore_volume_correction",
+ "initialization_method",
+ "observation_relative_errors",
+ "observation_minimum_errors",
+ "history_matching_end_date",
+ "ert_arguments",
+ "let_parameters",
+ "rock_property_settings",
+ "x_coarsening",
+ "y_coarsening",
+ "z_coarsening",
+ "satnum_generation_method",
+ "cleanup_file_suffixes",
+}
+
+INTERNAL_KEYS = {
+ "output_directory",
+ "resource_directory",
+ "reference_case_name",
+ "use_let_tables",
+ "significant_digits",
+ "original_nx",
+ "original_ny",
+ "original_nz",
+ "output_nx",
+ "output_ny",
+ "output_nz",
+ "original_cell_count",
+ "original_to_output_i",
+ "original_to_output_j",
+ "original_to_output_k",
+}
+
+MODEL_AXIS_LENGTHS = {
+ "norne": {"x_coarsening": 47, "y_coarsening": 113, "z_coarsening": 23},
+ "drogon": {"x_coarsening": 47, "y_coarsening": 74, "z_coarsening": 32},
+}
+
+MODEL_LET_NAMES = {
+ "norne": [
+ "lw",
+ "ew",
+ "tw",
+ "lo",
+ "eo",
+ "to",
+ "lg",
+ "eg",
+ "tg",
+ "log",
+ "eog",
+ "tog",
+ "lmlto",
+ "emlto",
+ "tmlto",
+ "lmltg",
+ "emltg",
+ "tmltg",
+ ],
+ "drogon": [
+ "lw",
+ "ew",
+ "tw",
+ "lo",
+ "eo",
+ "to",
+ "lg",
+ "eg",
+ "tg",
+ "log",
+ "eog",
+ "tog",
+ "lmlto",
+ "lmltg",
+ "kwow",
+ "kwoo",
+ "kwgw",
+ "kwgg",
+ ],
+}
+
+ROCK_PROPERTY_NAMES = {"PERMX", "PERMY", "PERMZ"}
+
+
+
+[docs]
+def create_deck_config(cmdargs: argparse.Namespace) -> ConfigViaDeck:
+ """Create a deck configuration from parsed command arguments.
+
+ Parameters
+ ----------
+ cmdargs : argparse.Namespace
+ Command-line arguments for the deck-based workflow.
+
+ Returns
+ -------
+ ConfigViaDeck
+ Configuration populated from the command-line values."""
+ return ConfigViaDeck(
+ output_directory=str(Path(cmdargs.output_directory).expanduser().resolve()),
+ flow_command=cmdargs.flow_command,
+ input_deck_name=Path(cmdargs.input_deck_path).stem,
+ input_deck_path=str(Path(cmdargs.input_deck_path).with_suffix("")),
+ active_cell_methods=cmdargs.active_cell_methods.split(","),
+ discrete_aggregation_method=cmdargs.discrete_aggregation_method.split(","),
+ continuous_aggregation_method=cmdargs.continuous_aggregation_method.split(","),
+ jump_thresholds=cmdargs.jump_thresholds.split(","),
+ output_deck_name=cmdargs.output_deck_name,
+ execution_mode=cmdargs.execution_mode,
+ include_prefix=cmdargs.include_prefix,
+ requested_ijk=[cmdargs.requested_ijk],
+ completion_removal_level=int(cmdargs.completion_removal_level),
+ deck_encoding=cmdargs.deck_encoding,
+ pore_volume_correction=int(cmdargs.pore_volume_correction),
+ correct_fluid_in_place=int(cmdargs.correct_fluid_in_place),
+ transmissibility_coarsening_method=int(
+ cmdargs.transmissibility_coarsening_method
+ ),
+ vicinity_specification=cmdargs.vicinity_specification,
+ grid_transformation=cmdargs.grid_transformation,
+ write_explicit_solution=int(cmdargs.write_explicit_solution) == 1,
+ dual_porosity_criterion=cmdargs.dual_porosity_criterion,
+ significant_digits=int(cmdargs.significant_digits),
+ refinement_enabled=bool(
+ cmdargs.x_refinement
+ or cmdargs.y_refinement
+ or cmdargs.z_refinement
+ or cmdargs.refinement
+ ),
+ coarsening_enabled=bool(
+ cmdargs.x_coarsening
+ or cmdargs.y_coarsening
+ or cmdargs.z_coarsening
+ or cmdargs.coarsening
+ ),
+ )
+
+
+
+
+[docs]
+def parse_axis_modifications(uniform: str, localized: list) -> tuple[NDArray, list]:
+ """Parse uniform or axis-specific grid modifications.
+
+ Uniform input contains one value for each axis. Axis-specific coarsening
+ also accepts one-based indices and inclusive ranges such as ``2:4,7``.
+
+ Parameters
+ ----------
+ uniform : str
+ Comma-separated x, y, and z modification values.
+ localized : list
+ Axis-specific specifications in x, y, and z order.
+
+ Returns
+ -------
+ cijk, axis_values
+ Uniform axis values and the three parsed axis-specific arrays. Only one
+ representation is populated."""
+ if uniform:
+ cijk = np.fromstring(uniform, sep=",", dtype=int)
+ refs: list = [[], [], []]
+ else:
+ cijk = np.array([])
+ refs = []
+ for i in range(3):
+ argument = localized[i]
+ if argument:
+ if ":" in argument:
+ values = [0]
+ index = 1
+ for value in argument.split(","):
+ entry = value.split(":")
+ start_index = int(entry[0])
+ values.extend([0] * max(0, start_index - index))
+ if len(entry) == 2:
+ end_index = int(entry[1])
+ values.extend([2] * max(0, end_index - start_index))
+ index = end_index
+ else:
+ index = start_index
+ values.append(0)
+ refs.append(values)
+ else:
+ refs.append(
+ list(np.fromstring(argument, sep=",", dtype=int).tolist())
+ )
+ else:
+ refs.append([])
+ return cijk, refs
+
+
+
+
+[docs]
+def _is_finite_number(value: Any) -> TypeGuard[int | float]:
+ """Check whether a value is a finite non-Boolean number.
+
+ Parameters
+ ----------
+ value : Any
+ Value to inspect.
+
+ Returns
+ -------
+ bool
+ Whether the value is a finite integer or floating-point number."""
+ return (
+ isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and math.isfinite(value)
+ )
+
+
+
+
+[docs]
+def _is_integer(value: Any) -> TypeGuard[int]:
+ """Check whether a value is a non-Boolean integer.
+
+ Parameters
+ ----------
+ value : Any
+ Value to inspect.
+
+ Returns
+ -------
+ bool
+ Whether the value is an integer and not a Boolean."""
+ return isinstance(value, int) and not isinstance(value, bool)
+
+
+
+
+[docs]
+def _add_validation_error(errors: list[str], message: str) -> None:
+ """Add a TOML validation error.
+
+ Parameters
+ ----------
+ errors : list[str]
+ Validation messages collected during the current validation pass.
+ message : str
+ Human-readable validation message."""
+ errors.append(message)
+
+
+
+
+[docs]
+def _warn(message: str) -> None:
+ """Display a TOML validation warning.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable warning passed to the shared terminal helper."""
+ pycopm_warning(message)
+
+
+
+
+[docs]
+def _validate_string(cfg_file: dict[str, Any], key: str, errors: list[str]) -> bool:
+ """Check that a TOML variable is a non-empty string.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ key : str
+ Configuration variable name.
+ errors : list[str]
+ Validation messages collected during the current validation pass.
+
+ Returns
+ -------
+ bool
+ Whether the variable is present and contains a non-empty string."""
+ if key not in cfg_file:
+ return False
+ value = cfg_file[key]
+ if not isinstance(value, str) or not value.strip():
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(key)} has invalid value "
+ f"{cli_error_value(str(value))}, expected "
+ f"{cli_correct_value('a non-empty string')}.",
+ )
+ return False
+ return True
+
+
+
+
+[docs]
+def _validate_integer(
+ cfg_file: dict[str, Any], key: str, errors: list[str], minimum: int = 0
+) -> bool:
+ """Check that a TOML variable is an integer within its lower bound.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ key : str
+ Configuration variable name.
+ errors : list[str]
+ Validation messages collected during the current validation pass.
+ minimum : int, optional
+ Inclusive lower bound for accepted values.
+
+ Returns
+ -------
+ bool
+ Whether the variable is present and satisfies the integer constraint."""
+ if key not in cfg_file:
+ return False
+ value = cfg_file[key]
+ if not _is_integer(value) or value < minimum:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(key)} has invalid value "
+ f"{cli_error_value(str(value))}, expected an integer "
+ f"greater than or equal to {minimum}.",
+ )
+ return False
+ return True
+
+
+
+
+[docs]
+def _validate_number_array(
+ cfg_file: dict[str, Any],
+ key: str,
+ errors: list[str],
+ *,
+ length: int | None = None,
+ minimum: float | None = None,
+ maximum: float | None = None,
+) -> bool:
+ """Check the shape and values of a numeric TOML array.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ key : str
+ Configuration variable name.
+ errors : list[str]
+ Validation messages collected during the current validation pass.
+ length : int | None, optional
+ Required number of entries.
+ minimum : float | None, optional
+ Inclusive lower bound for every entry.
+ maximum : float | None, optional
+ Inclusive upper bound for every entry.
+
+ Returns
+ -------
+ bool
+ Whether the variable is present and satisfies all array constraints."""
+ if key not in cfg_file:
+ return False
+ value = cfg_file[key]
+ if not isinstance(value, list):
+ _add_validation_error(errors, f"variable '{key}' must be an array.")
+ return False
+ valid = True
+ if length is not None and len(value) != length:
+ _add_validation_error(
+ errors, f"variable '{key}' has {len(value)} entries, expected {length}."
+ )
+ valid = False
+ for index, entry in enumerate(value):
+ if not _is_finite_number(entry):
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{key}[{index}]')} has invalid value "
+ f"{cli_error_value(str(entry))}, expected "
+ f"{cli_correct_value('a finite number')}.",
+ )
+ valid = False
+ elif minimum is not None and entry < minimum:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{key}[{index}]')} has invalid value "
+ f"{cli_error_value(str(entry))}, expected a value greater than or "
+ f"equal to {cli_correct_value(str(minimum))}.",
+ )
+ valid = False
+ elif maximum is not None and entry > maximum:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{key}[{index}]')} has invalid value "
+ f"{cli_error_value(str(entry))}, expected a value less than or "
+ f"equal to {cli_correct_value(str(maximum))}.",
+ )
+ valid = False
+ return valid
+
+
+
+
+[docs]
+def _validate_coarsening(
+ cfg_file: dict[str, Any], key: str, expected_length: int, errors: list[str]
+) -> None:
+ """Validate one model-specific axis coarsening array.
+
+ The array must contain non-negative integers, match the number of grid
+ boundaries for the selected model, and retain both outer boundaries.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ key : str
+ Name of the x, y, or z coarsening variable.
+ expected_length : int
+ Required number of entries for the selected reference model.
+ errors : list[str]
+ Validation messages collected during the current validation pass."""
+ if key not in cfg_file:
+ return
+ values = cfg_file[key]
+ if not isinstance(values, list):
+ _add_validation_error(errors, f"variable '{key}' must be an array.")
+ return
+ if len(values) != expected_length:
+ _add_validation_error(
+ errors,
+ f"variable '{key}' has {len(values)} entries, expected {expected_length}.",
+ )
+ for index, value in enumerate(values):
+ if not _is_integer(value) or value < 0:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{key}[{index}]')} has invalid value "
+ f"{cli_error_value(str(value))}, "
+ "expected a non-negative integer.",
+ )
+ if values and values[0] != 0:
+ _add_validation_error(
+ errors, f"variable '{key}[0]' must be 0 to retain the first grid boundary."
+ )
+ if values and values[-1] != 0:
+ _add_validation_error(
+ errors, f"variable '{key}[-1]' must be 0 to retain the last grid boundary."
+ )
+
+
+
+
+[docs]
+def _validate_let_parameters(
+ cfg_file: dict[str, Any], model: str | None, errors: list[str]
+) -> None:
+ """Validate the ordered LET-parameter definitions.
+
+ Each row contains a coefficient name, initial value, estimation flag,
+ distribution name, lower bound, and upper bound. The order is checked because
+ downstream table generation addresses coefficients by position.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ model : str | None
+ Normalized reference-model name, when valid.
+ errors : list[str]
+ Validation messages collected during the current validation pass."""
+ if "let_parameters" not in cfg_file:
+ return
+ rows = cfg_file["let_parameters"]
+ if not isinstance(rows, list):
+ _add_validation_error(
+ errors, "variable 'let_parameters' must be an array of arrays."
+ )
+ return
+ expected_names = MODEL_LET_NAMES.get(model) if model is not None else None
+ if expected_names and len(rows) != len(expected_names):
+ _add_validation_error(
+ errors,
+ f"variable 'let_parameters' has {len(rows)} rows, expected "
+ f"{len(expected_names)} for model '{model}'.",
+ )
+ seen: set[str] = set()
+ for index, row in enumerate(rows):
+ name = f"let_parameters[{index}]"
+ if not isinstance(row, list) or len(row) != 6:
+ size = len(row) if isinstance(row, list) else type(row).__name__
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(name)} has invalid shape "
+ f"{cli_error_value(str(size))}, expected "
+ f"{cli_correct_value('6 entries')}.",
+ )
+ continue
+ coefficient, initial, estimated, distribution, lower, upper = row
+ if not isinstance(coefficient, str) or not coefficient.strip():
+ _add_validation_error(
+ errors, f"variable '{name}[0]' must be a non-empty coefficient name."
+ )
+ elif coefficient in seen:
+ _add_validation_error(
+ errors, f"duplicate LET-parameter name '{coefficient}'."
+ )
+ else:
+ seen.add(coefficient)
+ if (
+ expected_names
+ and index < len(expected_names)
+ and coefficient != expected_names[index]
+ ):
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{name}[0]')} has invalid value "
+ f"{cli_error_value(str(coefficient))}, expected "
+ f"{cli_correct_value(expected_names[index])} at this position.",
+ )
+ if not _is_finite_number(initial):
+ _add_validation_error(
+ errors, f"variable '{name}[1]' must be a finite number."
+ )
+ if not _is_integer(estimated) or estimated not in {0, 1}:
+ _add_validation_error(errors, f"variable '{name}[2]' must be 0 or 1.")
+ if not isinstance(distribution, str) or not distribution.strip():
+ _add_validation_error(
+ errors, f"variable '{name}[3]' must be a non-empty distribution name."
+ )
+ if not _is_finite_number(lower) or not _is_finite_number(upper):
+ _add_validation_error(
+ errors, f"variables '{name}[4:6]' must be finite numbers."
+ )
+ elif lower >= upper:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(name)} has invalid uniform bounds "
+ f"{cli_error_value(str([lower, upper]))}, expected the lower "
+ f"bound to be less than {cli_correct_value('the upper bound')}.",
+ )
+ elif (
+ isinstance(distribution, str)
+ and distribution.lower() == "uniform"
+ and _is_finite_number(initial)
+ and not lower <= initial <= upper
+ ):
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{name}[1]')} has invalid value "
+ f"{cli_error_value(str(initial))}, expected a value between "
+ f"{cli_correct_value(str(lower))} and "
+ f"{cli_correct_value(str(upper))} for the uniform distribution.",
+ )
+
+
+
+
+[docs]
+def _validate_rock_properties(cfg_file: dict[str, Any], errors: list[str]) -> None:
+ """Validate rock-property history matching settings.
+
+ Each row contains a permeability name, estimation flag, and aggregation
+ method. Property names are normalized to uppercase after validation.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ TOML configuration values.
+ errors : list[str]
+ Validation messages collected during the current validation pass."""
+ if "rock_property_settings" not in cfg_file:
+ return
+ rows = cfg_file["rock_property_settings"]
+ if not isinstance(rows, list):
+ _add_validation_error(
+ errors, "variable 'rock_property_settings' must be an array of arrays."
+ )
+ return
+ seen: set[str] = set()
+ for index, row in enumerate(rows):
+ name = f"rock_property_settings[{index}]"
+ if not isinstance(row, list) or len(row) != 3:
+ _add_validation_error(
+ errors,
+ f"variable '{name}' must contain name, estimation flag, and aggregation method.",
+ )
+ continue
+ prop, estimated, method = row
+ if not isinstance(prop, str) or prop.upper() not in ROCK_PROPERTY_NAMES:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{name}[0]')} has invalid value "
+ f"{cli_error_value(str(prop))}, expected "
+ "PERMX, PERMY, or PERMZ.",
+ )
+ elif prop.upper() in seen:
+ _add_validation_error(errors, f"duplicate rock property '{prop.upper()}'.")
+ else:
+ seen.add(prop.upper())
+ row[0] = prop.upper()
+ if not _is_integer(estimated) or estimated not in {0, 1}:
+ _add_validation_error(errors, f"variable '{name}[1]' must be 0 or 1.")
+ if method not in {"max", "mean"}:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(f'{name}[2]')} has invalid value "
+ f"{cli_error_value(str(method))}, expected "
+ f"{cli_correct_value('max')} or {cli_correct_value('mean')}.",
+ )
+ missing = ROCK_PROPERTY_NAMES - seen
+ if missing:
+ _add_validation_error(
+ errors, f"missing rock property settings for {', '.join(sorted(missing))}."
+ )
+
+
+
+
+[docs]
+def _validate_toml(cfg_file: dict[str, Any]) -> dict[str, Any]:
+ """Validate and normalize TOML configuration values.
+
+ Unknown and internally managed variables are reported and removed. Remaining
+ errors are collected so the user receives one complete validation report.
+
+ Parameters
+ ----------
+ cfg_file : dict[str, Any]
+ Raw values loaded from the TOML configuration.
+
+ Returns
+ -------
+ dict[str, Any]
+ Validated and normalized values suitable for ``ConfigViaTOML``.
+
+ Raises
+ ------
+ SystemExit
+ If one or more configuration values are invalid."""
+ if not isinstance(cfg_file, dict):
+ pycopm_error(
+ f"invalid TOML content {cli_error_value(type(cfg_file).__name__)}, "
+ f"expected {cli_correct_value('a dictionary of configuration variables')}."
+ )
+ cfg_file = cfg_file.copy()
+ errors: list[str] = []
+
+ internal = sorted(INTERNAL_KEYS & cfg_file.keys())
+ if internal:
+ formatted = ", ".join(cli_warning_value(key) for key in internal)
+ plural = len(internal) != 1
+ pycopm_warning(
+ f"variable{'s' if plural else ''} {formatted} "
+ f"{'are' if plural else 'is'} managed internally and will be ignored."
+ )
+ for key in internal:
+ cfg_file.pop(key)
+ unknown = sorted(cfg_file.keys() - TOML_KEYS)
+ if unknown:
+ formatted = ", ".join(cli_warning_value(key) for key in unknown)
+ plural = len(unknown) != 1
+ pycopm_warning(
+ f"unknown TOML variable{'s' if plural else ''} {formatted} will be ignored."
+ )
+ for key in unknown:
+ cfg_file.pop(key)
+
+ required = set(TOML_KEYS) - {"satnum_generation_method"}
+ for key in sorted(required - cfg_file.keys()):
+ _add_validation_error(errors, f"missing required TOML variable '{key}'.")
+
+ _validate_string(cfg_file, "flow_command", errors)
+ if _validate_string(cfg_file, "model_name", errors):
+ model = cfg_file["model_name"].lower()
+ cfg_file["model_name"] = model
+ if model not in MODEL_AXIS_LENGTHS:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value('model_name')} has invalid value "
+ f"{cli_error_value(str(model))}, expected "
+ f"{cli_correct_value('norne')} or {cli_correct_value('drogon')}.",
+ )
+ else:
+ model = None
+
+ if _validate_string(cfg_file, "execution_mode", errors):
+ mode = cfg_file["execution_mode"].lower()
+ cfg_file["execution_mode"] = mode
+ if mode not in {"single-run", "files", "ert"}:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value('execution_mode')} has invalid value "
+ f"{cli_error_value(str(mode))}, "
+ "expected 'single-run', 'files', or 'ert'.",
+ )
+ else:
+ mode = None
+
+ for key, minimum in (
+ ("ensemble_size", 1),
+ ("max_parallel_realizations", 1),
+ ("max_realization_runtime_seconds", 0),
+ ("min_successful_realizations", 1),
+ ("random_seed", 0),
+ ):
+ _validate_integer(cfg_file, key, errors, minimum)
+
+ ensemble = cfg_file.get("ensemble_size")
+ parallel = cfg_file.get("max_parallel_realizations")
+ successful = cfg_file.get("min_successful_realizations")
+ if _is_integer(ensemble):
+ if _is_integer(parallel) and parallel > ensemble:
+ _add_validation_error(
+ errors,
+ "variable 'max_parallel_realizations' cannot exceed 'ensemble_size'.",
+ )
+ if _is_integer(successful) and successful > ensemble:
+ _add_validation_error(
+ errors,
+ "variable 'min_successful_realizations' cannot exceed 'ensemble_size'.",
+ )
+
+ for key, choices in (
+ ("saturation_function_method", {0, 1}),
+ ("pore_volume_correction", {0, 1}),
+ ("initialization_method", {0, 1}),
+ ):
+ if _validate_integer(cfg_file, key, errors) and cfg_file[key] not in choices:
+ _add_validation_error(
+ errors,
+ f"variable {cli_error_value(key)} has invalid value "
+ f"{cli_error_value(str(cfg_file[key]))}, "
+ f"expected one of {sorted(choices)}.",
+ )
+
+ if "satnum_generation_method" in cfg_file:
+ if _validate_integer(cfg_file, "satnum_generation_method", errors) and cfg_file[
+ "satnum_generation_method"
+ ] not in {0, 1, 2}:
+ _add_validation_error(
+ errors, "variable 'satnum_generation_method' must be 0, 1, or 2."
+ )
+ if model == "drogon":
+ pycopm_warning(
+ f"variable {cli_warning_value('satnum_generation_method')} is only "
+ f"effective for {cli_correct_value('model_name = norne')} and will be ignored."
+ )
+ cfg_file.pop("satnum_generation_method", None)
+ elif model == "norne":
+ cfg_file["satnum_generation_method"] = 0
+
+ _validate_number_array(
+ cfg_file, "observation_relative_errors", errors, length=3, minimum=0, maximum=1
+ )
+ _validate_number_array(
+ cfg_file, "observation_minimum_errors", errors, length=3, minimum=0
+ )
+
+ if "history_matching_end_date" in cfg_file:
+ value = cfg_file["history_matching_end_date"]
+ if isinstance(value, dt.datetime):
+ cfg_file["history_matching_end_date"] = value.date().isoformat()
+ elif isinstance(value, dt.date):
+ cfg_file["history_matching_end_date"] = value.isoformat()
+ elif isinstance(value, str):
+ try:
+ cfg_file["history_matching_end_date"] = dt.date.fromisoformat(
+ value
+ ).isoformat()
+ except ValueError:
+ _add_validation_error(
+ errors,
+ "variable 'history_matching_end_date' must be an ISO date in "
+ "YYYY-MM-DD format.",
+ )
+ else:
+ _add_validation_error(
+ errors,
+ "variable 'history_matching_end_date' must be a TOML date or ISO date string.",
+ )
+
+ _validate_string(cfg_file, "ert_arguments", errors)
+ if mode != "ert" and "ert_arguments" in cfg_file:
+ pycopm_warning(
+ f"variable {cli_warning_value('ert_arguments')} is not executed for "
+ f"{cli_warning_value(f'execution_mode = {mode}')}, but is retained for "
+ "generated ERT files."
+ )
+
+ if model in MODEL_AXIS_LENGTHS:
+ for key, length in MODEL_AXIS_LENGTHS[model].items():
+ _validate_coarsening(cfg_file, key, length, errors)
+
+ if "cleanup_file_suffixes" in cfg_file:
+ suffixes = cfg_file["cleanup_file_suffixes"]
+ if not isinstance(suffixes, list):
+ _add_validation_error(
+ errors, "variable 'cleanup_file_suffixes' must be an array of strings."
+ )
+ else:
+ for index, suffix in enumerate(suffixes):
+ if not isinstance(suffix, str) or not suffix.strip():
+ _add_validation_error(
+ errors,
+ f"variable 'cleanup_file_suffixes[{index}]' must be a non-empty string.",
+ )
+ elif any(character in suffix for character in "'\"/*?[]"):
+ _add_validation_error(
+ errors,
+ f"variable 'cleanup_file_suffixes[{index}]' contains unsafe "
+ "filename-pattern characters.",
+ )
+
+ _validate_let_parameters(cfg_file, model, errors)
+ _validate_rock_properties(cfg_file, errors)
+
+ if cfg_file.get("saturation_function_method") == 1 and not cfg_file.get(
+ "let_parameters"
+ ):
+ _add_validation_error(
+ errors, "LET saturation functions require a non-empty 'let_parameters'."
+ )
+
+ estimated_let = any(
+ isinstance(row, list) and len(row) >= 3 and row[2] == 1
+ for row in cfg_file.get("let_parameters", [])
+ )
+ estimated_rock = any(
+ isinstance(row, list) and len(row) >= 2 and row[1] == 1
+ for row in cfg_file.get("rock_property_settings", [])
+ )
+ if mode == "ert" and not (estimated_let or estimated_rock):
+ _add_validation_error(
+ errors,
+ "execution_mode 'ert' requires at least one estimated LET or rock-property parameter.",
+ )
+
+ if errors:
+ details = "\n".join(f" - {error}" for error in errors)
+ pycopm_error(f"invalid TOML configuration:\n{details}")
+ return cfg_file
+
+
+
+
+[docs]
+def load_toml_config(
+ input_file: str,
+ output_directory: str,
+ resource_directory: str,
+ significant_digits: int,
+) -> ConfigViaTOML:
+ """Load, validate, and initialize a TOML configuration.
+
+ Validation and normalization occur before the reference EGRID is opened and
+ before ``ConfigViaTOML`` is constructed.
+
+ Parameters
+ ----------
+ input_file : str
+ TOML configuration path.
+ output_directory : str
+ Generated-project directory.
+ resource_directory : str
+ Directory containing templates and reference simulations.
+ significant_digits : int
+ Precision used when writing floating-point values.
+
+ Returns
+ -------
+ ConfigViaTOML
+ Validated configuration populated with reference-grid metadata.
+
+ Raises
+ ------
+ SystemExit
+ If the TOML configuration is invalid."""
+ with open(input_file, "rb") as file_handle:
+ cfg_file = _validate_toml(tomllib.load(file_handle))
+
+ suffixes = cfg_file["cleanup_file_suffixes"]
+ cfg_file["cleanup_file_suffixes"] = ",".join(f"'{suffix}'" for suffix in suffixes)
+ for key in ("x_coarsening", "y_coarsening", "z_coarsening"):
+ cfg_file[key] = np.asarray(cfg_file[key], dtype=int)
+
+ name = "NORNE_ATW2013" if cfg_file["model_name"] == "norne" else "DROGON"
+ case_path = (
+ Path(resource_directory)
+ / "reference_simulation"
+ / cfg_file["model_name"]
+ / name
+ )
+ grid = OpmGrid(f"{case_path}.EGRID")
+ cfg = ConfigViaTOML(
+ output_directory=output_directory,
+ resource_directory=resource_directory,
+ significant_digits=significant_digits,
+ reference_case_name=name,
+ original_nx=grid.dimension[0],
+ original_ny=grid.dimension[1],
+ original_nz=grid.dimension[2],
+ original_cell_count=int(np.prod(grid.dimension)),
+ **cfg_file,
+ )
+ return cfg
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0902,R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,R1705
+
+"""Parse an OPM deck and update records for the modified grid."""
+
+import csv
+import os
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+from numpy.typing import NDArray
+
+from pycopm.config.config import ConfigViaDeck
+from pycopm.utils.vicinity import VicinityMaps
+
+csv.field_size_limit(sys.maxsize)
+
+
+
+[docs]
+@dataclass(slots=True)
+class _ParserState:
+ """Store temporary state while parsing an OPM deck.
+
+ The Boolean fields indicate active keyword blocks. The list fields track wells,
+ completions, and segmented-well records retained in the generated deck."""
+
+ dimens: bool = False
+ grid: bool = False
+ welspecs: bool = False
+ welsegs: bool = False
+ complump: bool = False
+ compdat: bool = False
+ compsegs: bool = False
+ mapaxes: bool = False
+ multregt: bool = False
+ process_edit: bool = False
+ editnnc: bool = False
+ multiply: bool = False
+ props: bool = False
+ operation: bool = False
+ regions: bool = False
+ equil: bool = False
+ faults: bool = False
+ multflt: bool = False
+ welldims: bool = False
+ skip_block: bool = False
+ aqucon: bool = False
+ aqunum: bool = False
+ aquancon: bool = False
+ bccon: bool = False
+ bwpr: bool = False
+ source: bool = False
+ pinch: bool = False
+ has_edit: bool = False
+
+ previous_completion: list[str] = field(default_factory=list)
+ compsegs_wells: list[str] = field(default_factory=list)
+ retained_wells: list[str] = field(default_factory=list)
+ completion_wells: list[str] = field(default_factory=list)
+ segmented_wells: list[str] = field(default_factory=list)
+
+ separator: str = ""
+ schedule_keyword: str = ""
+
+
+
+_SCHEDULE_KEYWORDS = frozenset(
+ {
+ "wconhist",
+ "wdfac",
+ "weltarg",
+ "wrftplt",
+ "compord",
+ "wtracer",
+ "wconinjh",
+ "wconinje",
+ "wconprod",
+ "wtest",
+ "welopen",
+ "wsegvalv",
+ "wecon",
+ "cskin",
+ "wpavedep",
+ }
+)
+
+
+
+[docs]
+def process_deck(
+ dck: ConfigViaDeck, vicinity: VicinityMaps
+) -> tuple[list[str], list[int]]:
+ """Rewrite deck records for the modified grid.
+
+ The parser updates dimensions, properties, grid-index ranges, wells, aquifers,
+ faults, and selected schedule records.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration and axis index mappings.
+ vicinity
+ Vicinity selection used when extracting a submodel.
+
+ Returns
+ -------
+ modified_deck, well_cell_indices
+ Rewritten deck lines and coarse cells containing well completions."""
+ modified_deck: list[str] = []
+ wellcind: list[int] = []
+ kwr = _ParserState()
+ hvicinity = bool(vicinity and vicinity.shape)
+ if dck.refinement_enabled:
+ _collect_segmented_well_names(dck, kwr)
+ elif dck.vicinity_specification:
+ _collect_vicinity_well_names(dck, kwr, vicinity)
+ deck_path = Path(f"{dck.input_deck_name}.DATA")
+ with deck_path.open("r", encoding=dck.deck_encoding) as deck_file:
+ for row in csv.reader(deck_file):
+ parsed_line = str(row)[2:-2].strip()
+ parsed_line = parsed_line.replace("\\t", " ")
+ parsed_line = parsed_line.replace("', '", ",")
+ parsed_line = parsed_line.replace("-- Generated : Petrel", "")
+ parsed_line = parsed_line.strip()
+ if not kwr.separator and parsed_line.count("-") > 70:
+ kwr.separator = parsed_line
+ if _handle_dimens(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_welldims(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_grid_props(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_props(dck, vicinity, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_regions(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_equil(dck, kwr, modified_deck, parsed_line):
+ continue
+ if not dck.grid_transformation:
+ if _handle_bwpr(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_wells(dck, kwr, modified_deck, parsed_line, hvicinity):
+ continue
+ if _handle_source(dck, kwr, modified_deck, parsed_line):
+ continue
+ if _handle_aquancon(dck, kwr, modified_deck, parsed_line):
+ continue
+ if dck.vicinity_specification:
+ if _handle_welsegs(kwr, modified_deck, parsed_line):
+ continue
+ if __handle_schedule_keyword(kwr, modified_deck, parsed_line):
+ continue
+ if _handle_segmented_wells(
+ dck, kwr, modified_deck, parsed_line, wellcind
+ ):
+ continue
+ if modified_deck:
+ for special_name in dck.special_keywords:
+ if (
+ f"{special_name}." in parsed_line.lower()
+ or f".{special_name}" in parsed_line.lower()
+ ) or (
+ modified_deck[-1] == "INCLUDE"
+ and special_name in parsed_line.lower()
+ ):
+ parsed_line = (
+ f"{dck.include_prefix}{special_name.upper()}.INC /"
+ )
+ modified_deck.append(parsed_line)
+ if (
+ len(modified_deck) > 1
+ and modified_deck[-2] == "INCLUDE"
+ and _include_contains_endbox(dck, parsed_line)
+ ):
+ modified_deck[-2] = "--" + modified_deck[-2]
+ modified_deck[-1] = "--" + modified_deck[-1]
+ return modified_deck, wellcind
+
+
+
+
+[docs]
+def _include_contains_endbox(dck: ConfigViaDeck, nrwo: str) -> bool:
+ """Return whether an included file contains ENDBOX."""
+ include_text = nrwo
+ if "--" in include_text:
+ include_text = include_text.split("--", maxsplit=1)[0]
+ include_text = include_text.replace(" /", "")
+ include_text = include_text.rstrip("/").strip().strip("'\"")
+ deck_path = Path(f"{dck.input_deck_name}.DATA").absolute()
+ include_path = (deck_path.parent / include_text).absolute()
+ if not include_path.exists():
+ return False
+ with include_path.open("r", encoding=dck.deck_encoding) as include_file:
+ for row in csv.reader(include_file):
+ parsed_line = str(row)[2:-2].strip()
+ if parsed_line == "ENDBOX":
+ return True
+ return False
+
+
+
+
+[docs]
+def __handle_schedule_keyword(
+ kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Filter supported schedule records by wells retained in the submodel."""
+ keyword_name = nrwo.lower()
+ if keyword_name in _SCHEDULE_KEYWORDS:
+ kwr.schedule_keyword = keyword_name
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.schedule_keyword:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.schedule_keyword = ""
+ if len(tokens) <= 1:
+ return False
+ if tokens[0].startswith("--"):
+ return True
+ well_name = tokens[0].replace("'", "")
+ if kwr.schedule_keyword == "wsegvalv" and well_name in kwr.segmented_wells:
+ return True
+ if well_name.endswith("*"):
+ well_prefix = well_name[:-1]
+ for retained_well in kwr.retained_wells:
+ if retained_well.startswith(well_prefix):
+ return False
+ return well_name not in kwr.retained_wells
+
+
+
+
+[docs]
+def _collect_vicinity_well_names(
+ dck: ConfigViaDeck, kwr: _ParserState, vicinity: VicinityMaps
+) -> None:
+ """Identify wells and segmented wells retained in the vicinity model."""
+ segmented_well = ""
+ deck_path = Path(f"{dck.input_deck_name}.DATA")
+ with deck_path.open("r", encoding=dck.deck_encoding) as deck_file:
+ for row in csv.reader(deck_file):
+ parsed_line = str(row)[2:-2].strip()
+ if parsed_line == "COMPDAT":
+ kwr.compdat = True
+ continue
+ if kwr.compdat:
+ tokens = parsed_line.split()
+ if tokens:
+ if tokens[0] == "/":
+ kwr.previous_completion = []
+ kwr.compdat = False
+ well_name = tokens[0].replace("'", "")
+ if well_name in kwr.retained_wells:
+ continue
+ if len(tokens) > 4 and not tokens[0].startswith("--"):
+ if well_name not in kwr.completion_wells:
+ kwr.completion_wells.append(well_name)
+ source_i = int(tokens[1])
+ source_j = int(tokens[2])
+ source_k1 = int(tokens[3])
+ source_k2 = int(tokens[4])
+ if (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k1]
+ * dck.original_to_output_k[source_k2]
+ > 0
+ ):
+ kwr.retained_wells.append(well_name)
+ if parsed_line == "COMPSEGS":
+ kwr.compsegs = True
+ continue
+ if kwr.compsegs:
+ tokens = parsed_line.split()
+ if tokens and tokens[0] == "/":
+ kwr.compsegs = False
+ if len(tokens) > 1 and not tokens[0].startswith("--"):
+ well_name = tokens[0].replace("'", "")
+ if well_name in kwr.completion_wells:
+ segmented_well = well_name
+ elif len(tokens) > 2:
+ source_i = int(tokens[0])
+ source_j = int(tokens[1])
+ source_k = int(tokens[2])
+ if (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k]
+ != 0
+ and segmented_well not in kwr.segmented_wells
+ ):
+ kwr.segmented_wells.append(segmented_well)
+ if vicinity and vicinity.selector:
+ selected_well_names = {vicinity.selector, f"'{vicinity.selector}'"}
+ kwr.segmented_wells = [
+ well_name.replace("'", "")
+ for well_name in kwr.segmented_wells
+ if well_name in selected_well_names
+ ]
+ kwr.retained_wells = [
+ well_name.replace("'", "")
+ for well_name in kwr.retained_wells
+ if well_name in selected_well_names
+ ]
+ kwr.completion_wells = [
+ well_name.replace("'", "")
+ for well_name in kwr.completion_wells
+ if well_name in selected_well_names
+ ]
+
+
+
+
+[docs]
+def _collect_segmented_well_names(dck: ConfigViaDeck, kwr: _ParserState) -> None:
+ """Identify wells requiring segmented-well completion handling."""
+ deck_path = Path(f"{dck.input_deck_name}.DATA")
+ with deck_path.open("r", encoding=dck.deck_encoding) as deck_file:
+ for row in csv.reader(deck_file):
+ parsed_line = str(row)[2:-2].strip()
+ if parsed_line == "COMPDAT":
+ kwr.compdat = True
+ continue
+ if kwr.compdat:
+ tokens = parsed_line.split()
+ if tokens:
+ if tokens[0] == "/":
+ kwr.compdat = False
+ well_name = tokens[0].replace("'", "")
+ if well_name in kwr.segmented_wells:
+ continue
+ if len(tokens) > 2 and not tokens[0].startswith("--"):
+ if not kwr.previous_completion:
+ kwr.previous_completion = tokens
+ else:
+ previous_well = kwr.previous_completion[0].replace("'", "")
+ changed_column = (
+ tokens[1] != kwr.previous_completion[1]
+ or tokens[2] != kwr.previous_completion[2]
+ )
+ if changed_column and well_name == previous_well:
+ kwr.segmented_wells.append(well_name)
+ kwr.previous_completion = tokens
+ if parsed_line == "COMPSEGS":
+ kwr.compsegs = True
+ continue
+ if kwr.compsegs:
+ tokens = parsed_line.split()
+ if len(tokens) > 1 and not tokens[0].startswith("--"):
+ well_name = tokens[0].replace("'", "")
+ kwr.compsegs_wells.append(well_name)
+ kwr.compsegs = False
+
+
+
+
+[docs]
+def _handle_dimens(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Replace the original DIMENS values with the modified grid dimensions."""
+ if nrwo == "DIMENS":
+ kwr.dimens = True
+ modified_deck.append(nrwo)
+ modified_deck.append(f"{dck.output_nx} {dck.output_ny} {dck.output_nz} /")
+ return True
+
+ if not kwr.dimens:
+ return False
+
+ tokens = nrwo.split()
+ if (
+ tokens
+ and not tokens[0].startswith("--")
+ and (tokens[-1] == "/" or tokens[0] == "/")
+ ):
+ kwr.dimens = False
+ return True
+
+
+
+
+[docs]
+def _handle_welldims(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update WELLDIMS for a refined grid."""
+ if not dck.refinement_enabled:
+ return False
+ if nrwo == "WELLDIMS":
+ kwr.welldims = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.welldims:
+ return False
+ tokens = nrwo.split()
+ if tokens and not tokens[0].startswith("--"):
+ if len(tokens) > 2:
+ tokens[1] = str(dck.output_nx + dck.output_ny + dck.output_nz)
+ modified_deck.append(" ".join(tokens))
+ if "/" in nrwo:
+ kwr.welldims = False
+ if tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.welldims = False
+ return True
+
+
+
+
+[docs]
+def _handle_props(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ kwr: _ParserState,
+ modified_deck: list[str],
+ nrwo: str,
+) -> bool:
+ """Handle the PROPS section and its supported operations."""
+ if nrwo == "PROPS" and not kwr.props:
+ kwr.props = True
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.props:
+ return False
+ if _handle_oper(dck, vicinity, kwr, modified_deck, nrwo):
+ return True
+ if nrwo in {"REGIONS", "SOLUTION"}:
+ kwr.props = False
+ return False
+
+
+
+
+[docs]
+def _handle_oper(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ kwr: _ParserState,
+ modified_deck: list[str],
+ nrwo: str,
+) -> bool:
+ """Update supported operation records for the modified grid."""
+ if nrwo in {"EQUALS", "COPY", "ADD", "MULTIPLY"}:
+ if nrwo == "COPY" and not kwr.props:
+ return False
+ kwr.operation = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.operation:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.operation = False
+ if len(tokens) > 7 and not tokens[0].startswith("--"):
+ if "PERM" in tokens[0]:
+ tokens[1] = "1"
+ source_i1 = int(tokens[2])
+ source_i2 = int(tokens[3])
+ source_j1 = int(tokens[4])
+ source_j2 = int(tokens[5])
+ source_k1 = int(tokens[6])
+ source_k2 = int(tokens[7])
+ if dck.refinement_enabled:
+ tokens[2] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[3] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[4] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[5] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[6] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[7] = str(dck.original_to_last_refined_k[source_k2])
+ elif dck.vicinity_specification:
+ intersects_i = (
+ vicinity.min_i - source_i1 + 1 > 0
+ or source_i2 - vicinity.max_i + 1 > 0
+ or dck.original_to_output_i[source_i2] > 0
+ )
+ intersects_j = (
+ vicinity.min_j - source_j1 + 1 > 0
+ or source_j2 - vicinity.max_j + 1 > 0
+ or dck.original_to_output_j[source_j2] > 0
+ )
+ intersects_k = (
+ vicinity.min_k - source_k1 + 1 > 0
+ or source_k2 - vicinity.max_k + 1 > 0
+ or dck.original_to_output_k[source_k2] > 0
+ )
+ if not (intersects_i and intersects_j and intersects_k):
+ return True
+ tokens[2] = str(max(1, dck.original_to_output_i[source_i1]))
+ tokens[3] = str(
+ dck.output_nx
+ if dck.original_to_output_i[source_i2] == 0
+ else dck.original_to_output_i[source_i2]
+ )
+ tokens[4] = str(max(1, dck.original_to_output_j[source_j1]))
+ tokens[5] = str(
+ dck.output_ny
+ if dck.original_to_output_j[source_j2] == 0
+ else dck.original_to_output_j[source_j2]
+ )
+ tokens[6] = str(max(1, dck.original_to_output_k[source_k1]))
+ tokens[7] = str(
+ dck.output_nz
+ if dck.original_to_output_k[source_k2] == 0
+ else dck.original_to_output_k[source_k2]
+ )
+ else:
+ tokens[2] = str(dck.original_to_output_i[source_i1])
+ tokens[3] = str(dck.original_to_output_i[source_i2])
+ tokens[4] = str(dck.original_to_output_j[source_j1])
+ tokens[5] = str(dck.original_to_output_j[source_j2])
+ tokens[6] = str(dck.original_to_output_k[source_k1])
+ tokens[7] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+ if not kwr.props:
+ modified_deck.append(nrwo)
+ return False
+
+
+
+
+[docs]
+def _handle_bwpr(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update BWPR grid indices for the modified grid."""
+ if nrwo == "BWPR":
+ kwr.bwpr = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.bwpr:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.bwpr = False
+ if len(tokens) > 2 and not tokens[0].startswith("--"):
+ source_i = int(tokens[0])
+ source_j = int(tokens[1])
+ source_k = int(tokens[2])
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k]
+ == 0
+ ):
+ return True
+ tokens[0] = str(dck.original_to_output_i[source_i])
+ tokens[1] = str(dck.original_to_output_j[source_j])
+ tokens[2] = str(dck.original_to_output_k[source_k])
+ modified_deck.append(" ".join(tokens))
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_regions(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Replace the REGIONS content with generated include files."""
+ if nrwo == "REGIONS" and not kwr.regions:
+ kwr.regions = True
+ modified_deck.append(nrwo)
+ if len(modified_deck) > 1 and modified_deck[-2].startswith("---"):
+ modified_deck.append(modified_deck[-2])
+ return True
+ if not kwr.regions:
+ return False
+ if nrwo != "SOLUTION":
+ return True
+ kwr.regions = False
+ for region_name in dck.regions_keywords:
+ modified_deck.append("INCLUDE")
+ modified_deck.append(f"'{dck.include_prefix}{region_name.upper()}.INC' /\n")
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ return False
+
+
+
+
+[docs]
+def _handle_equil(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Replace EQUIL with explicit initialization include files."""
+ if not dck.write_explicit_solution:
+ return False
+ if "EQUIL" in nrwo:
+ tokens = nrwo.split()
+ if not tokens or tokens[0] != "EQUIL":
+ return False
+ kwr.equil = True
+ modified_deck.append("--EQUIL --pycopm explicit initialization")
+ return True
+ if not kwr.equil:
+ return False
+ tokens = nrwo.split()
+ if tokens:
+ if tokens[0].startswith("--") or tokens[0][0].isdigit():
+ modified_deck.append("--" + nrwo)
+ else:
+ _append_explicit_solution_includes(dck, modified_deck)
+ modified_deck.append(tokens[0])
+ kwr.equil = False
+ return True
+
+
+
+
+[docs]
+def _append_explicit_solution_includes(
+ dck: ConfigViaDeck, modified_deck: list[str]
+) -> None:
+ """Append include statements for the explicit solution properties."""
+ for property_name in dck.solution_keywords:
+ modified_deck.append("INCLUDE")
+ modified_deck.append(f"'{dck.include_prefix}{property_name.upper()}.INC' /\n")
+
+
+
+
+[docs]
+def _handle_grid_props(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Replace GRID properties and preserve supported GRID-section keywords."""
+ if nrwo == "GRID" and not kwr.grid:
+ kwr.grid = True
+ modified_deck.append(nrwo)
+ if len(modified_deck) > 1 and modified_deck[-2].startswith("---"):
+ modified_deck.append(modified_deck[-2])
+ modified_deck.append("INIT")
+ for property_name in (
+ dck.base_keywords + dck.grids_keywords + dck.multipliers_keywords
+ ):
+ modified_deck.append("INCLUDE")
+ modified_deck.append(
+ f"'{dck.include_prefix}{property_name.upper()}.INC' /\n"
+ )
+ return True
+ if not kwr.grid:
+ return False
+ if _handle_fault(dck, kwr, modified_deck, nrwo):
+ return True
+ if _handle_mapaxes(kwr, modified_deck, nrwo):
+ return True
+ if _handle_aqunum(dck, kwr, modified_deck, nrwo):
+ return True
+ if _handle_aqucon(dck, kwr, modified_deck, nrwo):
+ return True
+ if _handle_aquancon(dck, kwr, modified_deck, nrwo):
+ return True
+ if _handle_bccon(dck, kwr, modified_deck, nrwo):
+ return True
+ if nrwo == "EDIT":
+ kwr.has_edit = True
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ modified_deck.append(nrwo)
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ if dck.transmissibility_coarsening_method == 0:
+ if _handle_pinch(kwr, modified_deck, nrwo):
+ return True
+ if _handle_multregt(kwr, modified_deck, nrwo):
+ return True
+ if _handle_multflt(kwr, modified_deck, nrwo):
+ return True
+ if nrwo == "EDIT":
+ kwr.process_edit = True
+ modified_deck.append("INCLUDE")
+ modified_deck.append(f"'{dck.include_prefix}PORV.INC' /\n")
+ if nrwo == "PROPS":
+ kwr.grid = False
+ if not kwr.process_edit:
+ if not kwr.has_edit:
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ modified_deck.append("EDIT")
+ if kwr.separator:
+ modified_deck.append(kwr.separator)
+ modified_deck.append("INCLUDE")
+ modified_deck.append(f"'{dck.include_prefix}PORV.INC' /\n")
+ if dck.transmissibility_coarsening_method > 0:
+ for property_name in ("tranx", "trany", "tranz"):
+ modified_deck.append("INCLUDE")
+ modified_deck.append(
+ f"'{dck.include_prefix}{property_name.upper()}.INC' /\n"
+ )
+ elif kwr.process_edit or (
+ kwr.has_edit and (dck.refinement_enabled or dck.vicinity_specification)
+ ):
+ if _handle_editnnc(dck, kwr, modified_deck, nrwo):
+ return True
+ if _handle_multiply(dck, kwr, modified_deck, nrwo):
+ return True
+ else:
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_aqunum(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update AQUNUM grid indices for the modified grid."""
+ if nrwo == "AQUNUM":
+ kwr.aqunum = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.aqunum:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.aqunum = False
+ if len(tokens) > 3 and not tokens[0].startswith("--"):
+ source_i = int(tokens[1])
+ source_j = int(tokens[2])
+ source_k = int(tokens[3])
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k]
+ == 0
+ ):
+ return True
+ tokens[1] = str(dck.original_to_output_i[source_i])
+ tokens[2] = str(dck.original_to_output_j[source_j])
+ tokens[3] = str(dck.original_to_output_k[source_k])
+ modified_deck.append(" ".join(tokens))
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_aquancon(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update AQUANCON grid-index ranges for the modified grid."""
+ if nrwo == "AQUANCON":
+ kwr.aquancon = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.aquancon:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.aquancon = False
+ return True
+ if len(tokens) <= 7 or tokens[0].startswith("--"):
+ return False
+ source_i1 = int(tokens[1])
+ source_i2 = int(tokens[2])
+ source_j1 = int(tokens[3])
+ source_j2 = int(tokens[4])
+ source_k1 = int(tokens[5])
+ source_k2 = int(tokens[6])
+ if dck.refinement_enabled:
+ direction = tokens[7]
+ expanded_tokens = tokens.copy()
+ mapped_k1 = int(dck.original_to_first_refined_k[source_k1])
+ mapped_k2 = int(dck.original_to_last_refined_k[source_k2])
+ expanded_tokens[5] = str(mapped_k1)
+ expanded_tokens[6] = str(mapped_k2)
+ if direction in {"I", "X"}:
+ mapped_i = int(dck.original_to_last_refined_i[source_i1])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"I-", "X-"}:
+ mapped_i = int(dck.original_to_first_refined_i[source_i1])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J", "Y"}:
+ mapped_j = int(dck.original_to_last_refined_j[source_j1])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J-", "Y-"}:
+ mapped_j = int(dck.original_to_first_refined_j[source_j1])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ tokens[1] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[2] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[3] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[4] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[5] = str(mapped_k1)
+ tokens[6] = str(mapped_k2)
+ return True
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[1] = str(dck.original_to_output_i[source_i1])
+ tokens[2] = str(dck.original_to_output_i[source_i2])
+ tokens[3] = str(dck.original_to_output_j[source_j1])
+ tokens[4] = str(dck.original_to_output_j[source_j2])
+ tokens[5] = str(dck.original_to_output_k[source_k1])
+ tokens[6] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+
+
+
+
+[docs]
+def _handle_aqucon(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update AQUCON grid-index ranges for the modified grid."""
+ if nrwo == "AQUCON":
+ kwr.aqucon = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.aqucon:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.aqucon = False
+ if len(tokens) <= 7 or tokens[0].startswith("--"):
+ return False
+ source_i1 = int(tokens[1])
+ source_i2 = int(tokens[2])
+ source_j1 = int(tokens[3])
+ source_j2 = int(tokens[4])
+ source_k1 = int(tokens[5])
+ source_k2 = int(tokens[6])
+ if dck.refinement_enabled:
+ direction = tokens[7]
+ expanded_tokens = tokens.copy()
+ mapped_k1 = int(dck.original_to_first_refined_k[source_k1])
+ mapped_k2 = int(dck.original_to_last_refined_k[source_k2])
+ expanded_tokens[5] = str(mapped_k1)
+ expanded_tokens[6] = str(mapped_k2)
+ if direction in {"I", "X"}:
+ mapped_i = int(dck.original_to_last_refined_i[source_i1])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"I-", "X-"}:
+ mapped_i = int(dck.original_to_first_refined_i[source_i1])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J", "Y"}:
+ mapped_j = int(dck.original_to_last_refined_j[source_j1])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J-", "Y-"}:
+ mapped_j = int(dck.original_to_first_refined_j[source_j1])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ tokens[1] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[2] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[3] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[4] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[5] = str(mapped_k1)
+ tokens[6] = str(mapped_k2)
+ return True
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[1] = str(dck.original_to_output_i[source_i1])
+ tokens[2] = str(dck.original_to_output_i[source_i2])
+ tokens[3] = str(dck.original_to_output_j[source_j1])
+ tokens[4] = str(dck.original_to_output_j[source_j2])
+ tokens[5] = str(dck.original_to_output_k[source_k1])
+ tokens[6] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+
+
+
+
+[docs]
+def _handle_multflt(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Preserve fault multiplier records from the input deck."""
+ if "MULTFLT" in nrwo:
+ tokens = nrwo.split()
+ if not tokens or tokens[0] != "MULTFLT":
+ return False
+ kwr.multflt = True
+ modified_deck.append(tokens[0])
+ return True
+ if not kwr.multflt:
+ return False
+ tokens = nrwo.split()
+ if tokens:
+ modified_deck.append(nrwo)
+ if tokens[0] == "/":
+ kwr.multflt = False
+ return True
+
+
+
+
+[docs]
+def _handle_mapaxes(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Preserve MAPAXES so the generated grids retain the same map view."""
+ if "MAPAXES" in nrwo:
+ tokens = nrwo.split()
+ if not tokens or tokens[0] != "MAPAXES":
+ return False
+ kwr.mapaxes = True
+ modified_deck.append(tokens[0])
+ return True
+ if not kwr.mapaxes:
+ return False
+ tokens = nrwo.split()
+ if tokens:
+ modified_deck.append(nrwo)
+ if tokens[-1] == "/" or tokens[0] == "/":
+ kwr.mapaxes = False
+ return True
+
+
+
+
+[docs]
+def _handle_pinch(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Preserve PINCH records from the input deck."""
+ if "PINCH" in nrwo:
+ tokens = nrwo.split()
+ if not tokens or tokens[0] != "PINCH":
+ return False
+ kwr.pinch = True
+ modified_deck.append(tokens[0])
+ return True
+ if not kwr.pinch:
+ return False
+ tokens = nrwo.split()
+ if tokens and not tokens[0].startswith("--"):
+ modified_deck.append(nrwo)
+ if "/" in tokens[0] or "/" in tokens[-1]:
+ kwr.pinch = False
+ return True
+
+
+
+
+[docs]
+def _handle_multregt(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Preserve MULTREGT records from the GRID section."""
+ if "MULTREGT" in nrwo and "/" not in nrwo:
+ tokens = nrwo.split()
+ if not tokens or tokens[0] != "MULTREGT":
+ return False
+ kwr.multregt = True
+ modified_deck.append(tokens[0])
+ return True
+ if not kwr.multregt:
+ return False
+ tokens = nrwo.split()
+ if tokens:
+ modified_deck.append(nrwo)
+ if tokens[0] == "/":
+ kwr.multregt = False
+ return True
+
+
+
+
+[docs]
+def _handle_bccon(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update BCCON grid-index ranges for the modified grid."""
+ if nrwo == "BCCON":
+ kwr.bccon = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.bccon:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.bccon = False
+ return True
+ if len(tokens) <= 6 or tokens[0].startswith("--"):
+ return False
+ source_i1 = int(tokens[1])
+ source_i2 = int(tokens[2])
+ source_j1 = int(tokens[3])
+ source_j2 = int(tokens[4])
+ source_k1 = int(tokens[5])
+ source_k2 = int(tokens[6])
+ if dck.refinement_enabled:
+ tokens[1] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[2] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[3] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[4] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[5] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[6] = str(dck.original_to_last_refined_k[source_k2])
+ else:
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[1] = str(dck.original_to_output_i[source_i1])
+ tokens[2] = str(dck.original_to_output_i[source_i2])
+ tokens[3] = str(dck.original_to_output_j[source_j1])
+ tokens[4] = str(dck.original_to_output_j[source_j2])
+ tokens[5] = str(dck.original_to_output_k[source_k1])
+ tokens[6] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+
+
+
+
+[docs]
+def _handle_multiply(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update MULTIPLY grid-index ranges for the modified grid."""
+ if nrwo == "MULTIPLY":
+ kwr.multiply = True
+ modified_deck.append(nrwo)
+ return True
+ if kwr.multiply:
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.multiply = False
+ if len(tokens) > 7 and not tokens[0].startswith("--"):
+ source_i1 = int(tokens[2])
+ source_i2 = int(tokens[3])
+ source_j1 = int(tokens[4])
+ source_j2 = int(tokens[5])
+ source_k1 = int(tokens[6])
+ source_k2 = int(tokens[7])
+ if dck.refinement_enabled:
+ tokens[2] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[3] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[4] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[5] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[6] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[7] = str(dck.original_to_last_refined_k[source_k2])
+ else:
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[2] = str(dck.original_to_output_i[source_i1])
+ tokens[3] = str(dck.original_to_output_i[source_i2])
+ tokens[4] = str(dck.original_to_output_j[source_j1])
+ tokens[5] = str(dck.original_to_output_j[source_j2])
+ tokens[6] = str(dck.original_to_output_k[source_k1])
+ tokens[7] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+ return True
+
+
+
+
+[docs]
+def _handle_editnnc(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update EDITNNC grid indices for the modified grid."""
+ if nrwo == "EDITNNC":
+ kwr.editnnc = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.editnnc:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.editnnc = False
+ if len(tokens) <= 5 or tokens[0].startswith("--"):
+ return False
+ source_i1 = int(tokens[0])
+ source_j1 = int(tokens[1])
+ source_k1 = int(tokens[2])
+ source_i2 = int(tokens[3])
+ source_j2 = int(tokens[4])
+ source_k2 = int(tokens[5])
+ if dck.refinement_enabled:
+ tokens[0] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[1] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[2] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[3] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[4] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[5] = str(dck.original_to_last_refined_k[source_k2])
+ else:
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[0] = str(dck.original_to_output_i[source_i1])
+ tokens[1] = str(dck.original_to_output_j[source_j1])
+ tokens[2] = str(dck.original_to_output_k[source_k1])
+ tokens[3] = str(dck.original_to_output_i[source_i2])
+ tokens[4] = str(dck.original_to_output_j[source_j2])
+ tokens[5] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+
+
+
+
+[docs]
+def _handle_fault(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update FAULTS grid-index ranges for the modified grid."""
+ if nrwo == "FAULTS":
+ kwr.faults = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.faults:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ modified_deck.append(nrwo)
+ kwr.faults = False
+ if len(tokens) <= 7 or tokens[0].startswith("--"):
+ return False
+ source_i1 = int(tokens[1])
+ source_i2 = int(tokens[2])
+ source_j1 = int(tokens[3])
+ source_j2 = int(tokens[4])
+ source_k1 = int(tokens[5])
+ source_k2 = int(tokens[6])
+ if dck.refinement_enabled:
+ direction = tokens[7]
+ expanded_tokens = tokens.copy()
+ mapped_k1 = int(dck.original_to_first_refined_k[source_k1])
+ mapped_k2 = int(dck.original_to_last_refined_k[source_k2])
+ expanded_tokens[5] = str(mapped_k1)
+ expanded_tokens[6] = str(mapped_k2)
+ if direction in {"I", "X"}:
+ mapped_i = int(dck.original_to_last_refined_i[source_i1])
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"I-", "X-"}:
+ mapped_i = int(dck.original_to_first_refined_i[source_i1])
+ mapped_j1 = int(dck.original_to_first_refined_j[source_j1])
+ mapped_j2 = int(dck.original_to_last_refined_j[source_j2])
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ for mapped_j in range(mapped_j1, mapped_j2 + 1):
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J", "Y"}:
+ mapped_j = int(dck.original_to_last_refined_j[source_j1])
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ elif direction in {"J-", "Y-"}:
+ mapped_j = int(dck.original_to_first_refined_j[source_j1])
+ mapped_i1 = int(dck.original_to_first_refined_i[source_i1])
+ mapped_i2 = int(dck.original_to_last_refined_i[source_i2])
+ expanded_tokens[3] = str(mapped_j)
+ expanded_tokens[4] = str(mapped_j)
+ for mapped_i in range(mapped_i1, mapped_i2 + 1):
+ expanded_tokens[1] = str(mapped_i)
+ expanded_tokens[2] = str(mapped_i)
+ modified_deck.append(" ".join(expanded_tokens))
+ tokens[1] = str(dck.original_to_first_refined_i[source_i1])
+ tokens[2] = str(dck.original_to_last_refined_i[source_i2])
+ tokens[3] = str(dck.original_to_first_refined_j[source_j1])
+ tokens[4] = str(dck.original_to_last_refined_j[source_j2])
+ tokens[5] = str(mapped_k1)
+ tokens[6] = str(mapped_k2)
+ return True
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i1] == 0
+ or dck.original_to_output_i[source_i2] == 0
+ or dck.original_to_output_j[source_j1] == 0
+ or dck.original_to_output_j[source_j2] == 0
+ or dck.original_to_output_k[source_k1] == 0
+ or dck.original_to_output_k[source_k2] == 0
+ ):
+ return True
+ tokens[1] = str(dck.original_to_output_i[source_i1])
+ tokens[2] = str(dck.original_to_output_i[source_i2])
+ tokens[3] = str(dck.original_to_output_j[source_j1])
+ tokens[4] = str(dck.original_to_output_j[source_j2])
+ tokens[5] = str(dck.original_to_output_k[source_k1])
+ tokens[6] = str(dck.original_to_output_k[source_k2])
+ modified_deck.append(" ".join(tokens))
+ return True
+
+
+
+
+[docs]
+def _handle_welsegs(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Filter WELSEGS records by wells retained in the submodel."""
+ if nrwo == "WELSEGS":
+ kwr.welsegs = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.welsegs:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.welsegs = False
+ if kwr.skip_block:
+ kwr.skip_block = False
+ return True
+ if len(tokens) > 1:
+ if not tokens[0].startswith("--") and modified_deck[-1] == "WELSEGS":
+ well_name = tokens[0].replace("'", "")
+ if (
+ well_name not in kwr.retained_wells
+ or well_name not in kwr.segmented_wells
+ ):
+ del modified_deck[-1]
+ if modified_deck and modified_deck[-1] == "WELSEGS":
+ del modified_deck[-1]
+ kwr.skip_block = True
+ return True
+ elif not tokens[0].startswith("--") and not kwr.skip_block:
+ modified_deck.append(nrwo)
+ return True
+ else:
+ return True
+ elif kwr.skip_block:
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_compsegs(kwr: _ParserState, modified_deck: list[str], nrwo: str) -> bool:
+ """Filter COMPSEGS records by wells retained in the submodel."""
+ if nrwo == "COMPSEGS":
+ kwr.compsegs = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.compsegs:
+ return False
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.compsegs = False
+ if kwr.skip_block:
+ kwr.skip_block = False
+ return True
+ if len(tokens) > 1:
+ if not tokens[0].startswith("--") and modified_deck[-1] == "COMPSEGS":
+ well_name = tokens[0].replace("'", "")
+ if well_name not in kwr.retained_wells:
+ del modified_deck[-1]
+ if modified_deck and modified_deck[-1] == "COMPSEGS":
+ del modified_deck[-1]
+ kwr.skip_block = True
+ return True
+ elif not kwr.skip_block:
+ modified_deck.append(nrwo)
+ return True
+ else:
+ return True
+ elif kwr.skip_block:
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_segmented_wells(
+ dck: ConfigViaDeck,
+ kwr: _ParserState,
+ modified_deck: list[str],
+ nrwo: str,
+ wellcind: list,
+) -> bool:
+ """Update COMPDAT, COMPSEGS, and COMPLUMP records for the modified grid."""
+ if nrwo == "COMPSEGS":
+ kwr.compsegs = True
+ modified_deck.append(nrwo)
+ return True
+ if kwr.compdat:
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.previous_completion = []
+ kwr.compdat = False
+ if len(tokens) > 4 and not tokens[0].startswith("--"):
+ well_name = tokens[0].replace("'", "")
+ source_i = int(tokens[1])
+ source_j = int(tokens[2])
+ source_k1 = int(tokens[3])
+ source_k2 = int(tokens[4])
+ if dck.vicinity_specification and (
+ well_name not in kwr.retained_wells
+ or dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k1]
+ * dck.original_to_output_k[source_k2]
+ == 0
+ ):
+ return True
+ if (
+ dck.completion_removal_level > 0
+ and len(tokens) > 7
+ and tokens[7] != "/"
+ ):
+ tokens[7] = "1*"
+ if (
+ dck.completion_removal_level > 0
+ and len(tokens) > 9
+ and tokens[9] not in {"1*", "2*", "3*", "/"}
+ ):
+ tokens[9] = "1*"
+ if (
+ dck.completion_removal_level > 1
+ and len(tokens) > 12
+ and tokens[-2] != "/"
+ ):
+ tokens[-2] = ""
+ tokens[1] = str(dck.original_to_output_i[source_i])
+ tokens[2] = str(dck.original_to_output_j[source_j])
+ if dck.refinement_enabled:
+ if kwr.previous_completion:
+ previous_tokens = kwr.previous_completion
+ previous_well = previous_tokens[0].replace("'", "")
+ previous_source_i = int(previous_tokens[1])
+ previous_source_j = int(previous_tokens[2])
+ previous_source_k1 = int(previous_tokens[3])
+ previous_source_k2 = int(previous_tokens[4])
+ previous_i = int(dck.original_to_output_i[previous_source_i])
+ previous_j = int(dck.original_to_output_j[previous_source_j])
+ if (
+ well_name == previous_well
+ and previous_well not in kwr.compsegs_wells
+ and (
+ tokens[1] != str(previous_i) or tokens[2] != str(previous_j)
+ )
+ ):
+ tokens[3] = str(dck.original_to_output_k[source_k1])
+ tokens[4] = str(dck.original_to_output_k[source_k2])
+ current_completion = tokens.copy()
+ previous_completion = tokens.copy()
+ previous_completion[3] = str(
+ dck.original_to_output_k[previous_source_k1]
+ )
+ previous_completion[4] = str(
+ dck.original_to_output_k[previous_source_k2]
+ )
+ if int(tokens[1]) != previous_i:
+ difference = int(tokens[1]) - previous_i
+ for offset in range(abs(difference) - 1):
+ intermediate_i = previous_i + int(
+ (offset + 1) * difference / abs(difference)
+ )
+ current_completion[1] = str(intermediate_i)
+ previous_completion[1] = str(intermediate_i)
+ if offset < (abs(difference) - 1) / 2:
+ modified_deck.append(" ".join(previous_completion))
+ else:
+ modified_deck.append(" ".join(current_completion))
+ elif int(tokens[2]) != previous_j:
+ difference = int(tokens[2]) - previous_j
+ for offset in range(abs(difference) - 1):
+ intermediate_j = previous_j + int(
+ (offset + 1) * difference / abs(difference)
+ )
+ current_completion[2] = str(intermediate_j)
+ previous_completion[2] = str(intermediate_j)
+ if offset < (abs(difference) - 1) / 2:
+ modified_deck.append(" ".join(previous_completion))
+ else:
+ modified_deck.append(" ".join(current_completion))
+ elif (
+ well_name == previous_well
+ and previous_well not in kwr.compsegs_wells
+ and previous_well in kwr.segmented_wells
+ and tokens[1] == str(previous_i)
+ and tokens[2] == str(previous_j)
+ and dck.original_to_output_k[source_k1]
+ != dck.original_to_output_k[previous_source_k1]
+ ):
+ mapped_k = int(dck.original_to_output_k[source_k1])
+ previous_k = int(dck.original_to_output_k[previous_source_k1])
+ if previous_k < mapped_k:
+ k_values = range(previous_k, mapped_k)
+ else:
+ k_values = range(mapped_k, previous_k)
+ for mapped_k_value in k_values:
+ tokens[3] = str(mapped_k_value + 1)
+ tokens[4] = str(mapped_k_value + 1)
+ modified_deck.append(" ".join(tokens))
+ kwr.previous_completion = nrwo.split()
+ return True
+ elif well_name in kwr.segmented_wells + kwr.compsegs_wells:
+ tokens[3] = str(dck.original_to_output_k[source_k1])
+ tokens[4] = str(dck.original_to_output_k[source_k2])
+ else:
+ tokens[3] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[4] = str(dck.original_to_last_refined_k[source_k2])
+ elif well_name in kwr.segmented_wells + kwr.compsegs_wells:
+ tokens[3] = str(dck.original_to_output_k[source_k1])
+ tokens[4] = str(dck.original_to_output_k[source_k2])
+ else:
+ tokens[3] = str(dck.original_to_first_refined_k[source_k1])
+ tokens[4] = str(dck.original_to_last_refined_k[source_k2])
+ else:
+ tokens[3] = str(dck.original_to_output_k[source_k1])
+ tokens[4] = str(dck.original_to_output_k[source_k2])
+ if dck.coarsening_enabled and dck.transmissibility_coarsening_method > 0:
+ completion_i = int(tokens[1])
+ completion_j = int(tokens[2])
+ for completion_k in range(int(tokens[3]), int(tokens[4]) + 1):
+ cell_index = (
+ completion_i
+ - 1
+ + (completion_j - 1) * dck.output_nx
+ + (completion_k - 1) * dck.output_nx * dck.output_ny
+ )
+ wellcind.append(cell_index)
+ modified_deck.append(" ".join(tokens))
+ kwr.previous_completion = nrwo.split()
+ return True
+ if kwr.compsegs:
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.compsegs = False
+ if modified_deck[-1].split()[0] in kwr.completion_wells:
+ del modified_deck[-1]
+ del modified_deck[-1]
+ return True
+ if (
+ len(tokens) > 1
+ and not tokens[0].startswith("--")
+ and modified_deck[-1].split()[0] == "COMPSEGS"
+ and dck.vicinity_specification
+ ):
+ well_name = tokens[0].replace("'", "")
+ if (
+ well_name not in kwr.retained_wells
+ or well_name not in kwr.segmented_wells
+ ):
+ del modified_deck[-1]
+ del modified_deck[-1]
+ return True
+ if len(tokens) > 2:
+ if not tokens[0].startswith("--"):
+ if dck.vicinity_specification:
+ well_name = tokens[0].replace("'", "")
+ if (well_name not in kwr.retained_wells and len(tokens) < 4) or (
+ dck.original_to_output_i[int(tokens[0])]
+ * dck.original_to_output_j[int(tokens[1])]
+ * dck.original_to_output_k[int(tokens[2])]
+ == 0
+ ):
+ return True
+ tokens[0] = str(dck.original_to_output_i[int(tokens[0])])
+ tokens[1] = str(dck.original_to_output_j[int(tokens[1])])
+ tokens[2] = str(dck.original_to_output_k[int(tokens[2])])
+ modified_deck.append(" ".join(tokens))
+ return True
+ tokens[0] = str(dck.original_to_output_i[int(tokens[0])])
+ tokens[1] = str(dck.original_to_output_j[int(tokens[1])])
+ tokens[2] = str(dck.original_to_output_k[int(tokens[2])])
+ modified_deck.append(" ".join(tokens))
+ return True
+ return True
+ if kwr.complump:
+ tokens = nrwo.split()
+ if tokens and tokens[0] == "/":
+ kwr.complump = False
+ if modified_deck[-1].split()[0] in kwr.completion_wells:
+ del modified_deck[-1]
+ del modified_deck[-1]
+ return True
+ if (
+ len(tokens) > 1
+ and not tokens[0].startswith("--")
+ and modified_deck[-1].split()[0] == "COMPLUMP"
+ and dck.vicinity_specification
+ ):
+ well_name = tokens[0].replace("'", "")
+ if (
+ well_name not in kwr.retained_wells
+ or well_name not in kwr.segmented_wells
+ ):
+ del modified_deck[-1]
+ del modified_deck[-1]
+ return True
+ if len(tokens) > 2:
+ if not tokens[0].startswith("--"):
+ original_tokens = tokens.copy()
+ position_offset = 0
+ for position, value in enumerate(original_tokens):
+ if "*" in value:
+ tokens.pop(position + position_offset)
+ repeat_count = int(value[0])
+ for _ in range(repeat_count):
+ tokens.insert(position + position_offset, "1*")
+ position_offset += repeat_count - 1
+ if dck.vicinity_specification:
+ well_name = tokens[0].replace("'", "")
+ for value, axis_name in zip(
+ tokens[1:5], ("i", "j", "k", "k"), strict=True
+ ):
+ if (
+ "*" not in value
+ and getattr(dck, f"{axis_name}c")[int(value)] == 0
+ ):
+ return True
+ if well_name not in kwr.retained_wells:
+ return True
+ for position, axis_name in zip(
+ range(1, 5), ("i", "j", "k", "k"), strict=True
+ ):
+ if "*" not in tokens[position]:
+ source_index = int(tokens[position])
+ tokens[position] = str(
+ getattr(dck, f"{axis_name}c")[source_index]
+ )
+ modified_deck.append(" ".join(tokens))
+ return True
+ for position, axis_name in zip(
+ range(1, 5), ("i", "j", "k", "k"), strict=True
+ ):
+ if "*" not in tokens[position]:
+ source_index = int(tokens[position])
+ tokens[position] = str(
+ getattr(dck, f"{axis_name}c")[source_index]
+ )
+ modified_deck.append(" ".join(tokens))
+ return True
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_wells(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str, hv: bool
+) -> bool:
+ """Update well-head grid indices and activate completion handlers."""
+ if nrwo == "WELSPECS":
+ kwr.welspecs = True
+ modified_deck.append(nrwo)
+ return True
+ if kwr.welspecs:
+ tokens = nrwo.split()
+ if len(tokens) > 3 and not tokens[0].startswith("--"):
+ well_name = tokens[0].replace("'", "")
+ source_i = int(tokens[2])
+ source_j = int(tokens[3])
+ if dck.vicinity_specification:
+ if well_name not in kwr.retained_wells:
+ return True
+ if hv:
+ mapped_i = int(dck.original_to_output_i[source_i])
+ if mapped_i == 0:
+ for offset in range(dck.output_nx):
+ lower_i = source_i - offset
+ upper_i = source_i + offset
+ if (
+ lower_i >= 0
+ and int(dck.original_to_output_i[lower_i]) > 0
+ ):
+ mapped_i = int(dck.original_to_output_i[lower_i])
+ break
+ if (
+ upper_i < len(dck.original_to_output_i)
+ and int(dck.original_to_output_i[upper_i]) > 0
+ ):
+ mapped_i = int(dck.original_to_output_i[upper_i])
+ break
+ if mapped_i > 0:
+ tokens[2] = str(mapped_i)
+ mapped_j = int(dck.original_to_output_j[source_j])
+ if mapped_j == 0:
+ for offset in range(dck.output_ny):
+ lower_j = source_j - offset
+ upper_j = source_j + offset
+ if (
+ lower_j >= 0
+ and int(dck.original_to_output_j[lower_j]) > 0
+ ):
+ mapped_j = int(dck.original_to_output_j[lower_j])
+ break
+ if (
+ upper_j < len(dck.original_to_output_j)
+ and int(dck.original_to_output_j[upper_j]) > 0
+ ):
+ mapped_j = int(dck.original_to_output_j[upper_j])
+ break
+ if mapped_j > 0:
+ tokens[3] = str(mapped_j)
+ modified_deck.append(" ".join(tokens))
+ return True
+ if (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ == 0
+ ):
+ tokens[2] = "1"
+ tokens[3] = "1"
+ modified_deck.append(" ".join(tokens))
+ return True
+ tokens[2] = str(dck.original_to_output_i[source_i])
+ tokens[3] = str(dck.original_to_output_j[source_j])
+ modified_deck.append(" ".join(tokens))
+ return True
+ if tokens and tokens[0] == "/":
+ kwr.welspecs = False
+ if nrwo == "COMPDAT":
+ kwr.compdat = True
+ modified_deck.append(nrwo)
+ return True
+ if nrwo == "COMPLUMP":
+ kwr.complump = True
+ modified_deck.append(nrwo)
+ return True
+ return False
+
+
+
+
+[docs]
+def _handle_source(
+ dck: ConfigViaDeck, kwr: _ParserState, modified_deck: list[str], nrwo: str
+) -> bool:
+ """Update SOURCE grid indices for the modified grid."""
+ if nrwo == "SOURCE":
+ kwr.source = True
+ modified_deck.append(nrwo)
+ return True
+ if not kwr.source:
+ return False
+ tokens = nrwo.split()
+ if len(tokens) > 2 and not tokens[0].startswith("--"):
+ source_i = int(tokens[0])
+ source_j = int(tokens[1])
+ source_k = int(tokens[2])
+ if dck.vicinity_specification and (
+ dck.original_to_output_i[source_i]
+ * dck.original_to_output_j[source_j]
+ * dck.original_to_output_k[source_k]
+ == 0
+ ):
+ return True
+ tokens[0] = str(dck.original_to_output_i[source_i])
+ tokens[1] = str(dck.original_to_output_j[source_j])
+ tokens[2] = str(dck.original_to_output_k[source_k])
+ modified_deck.append(" ".join(tokens))
+ return True
+ if tokens and tokens[0] == "/":
+ kwr.source = False
+ return False
+
+
+
+
+[docs]
+def _scan_deck_file(
+ dck: ConfigViaDeck, file_path: str | Path
+) -> tuple[list[str], bool, NDArray]:
+ """Scan a deck file for includes and directional multipliers.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration providing the file encoding.
+ file_path
+ DATA or include file to scan.
+
+ Returns
+ -------
+ includes, has_main_multflt, multipliers
+ Resolved include paths, whether the main deck contains ``MULTFLT``, and
+ flags for ``MULTX``, ``MULTX-``, ``MULTY``, ``MULTY-``, ``MULTZ``, and
+ ``MULTZ-``."""
+ path = Path(file_path)
+ includes: list[str] = []
+ include_pending = False
+ maindeckmultflt = False
+ mults = np.array([False, False, False, False, False, False])
+ is_main_deck = ".DATA" in str(path)
+ base_directory = path.resolve().parent
+ with path.open("r", encoding=dck.deck_encoding) as file_handle:
+ for csv_row in csv.reader(file_handle):
+ deck_line = str(csv_row)[2:-2].strip()
+ if include_pending:
+ include_path = deck_line.split("--", maxsplit=1)[0]
+ include_path = include_path.replace(" /", "").rstrip("/").strip()
+ include_path = include_path.strip("'\"")
+ resolved_include = Path(os.path.normpath(base_directory / include_path))
+ if resolved_include.exists():
+ includes.append(str(resolved_include))
+ include_pending = False
+ continue
+ mults = _mark_multiplier_keyword(deck_line, mults)
+ if deck_line == "INCLUDE":
+ include_pending = True
+ if is_main_deck and deck_line == "MULTFLT":
+ maindeckmultflt = True
+ return includes, maindeckmultflt, mults
+
+
+
+
+[docs]
+def _mark_multiplier_keyword(deck_line: str, mults: NDArray) -> NDArray:
+ """Set the corresponding flag if a multiplier keyword is found."""
+ keywords = deck_line.split()
+ for i, multiplier in enumerate(
+ ["multx", "multx-", "multy", "multy-", "multz", "multz-"]
+ ):
+ keyword = multiplier.upper()
+ if deck_line == keyword or (len(keywords) > 1 and keywords[0] == keyword):
+ mults[i] = True
+ return mults
+ return mults
+
+
+
+
+[docs]
+def find_multiplier_keywords(dck: ConfigViaDeck) -> tuple[bool, NDArray]:
+ """Find directional multiplier keywords in nested includes.
+
+ At most three levels of included files are scanned.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration identifying the input deck and encoding.
+
+ Returns
+ -------
+ has_main_multflt, multipliers
+ Whether the main deck contains ``MULTFLT`` and directional multiplier
+ flags in x, x-, y, y-, z, and z- order."""
+ multipliers_values = np.array([False, False, False, False, False, False])
+ maindeckmultflt = False
+ included_files, multflt, mults = _scan_deck_file(dck, f"{dck.input_deck_name}.DATA")
+ maindeckmultflt = maindeckmultflt or multflt
+ multipliers_values = multipliers_values | mults
+ first_level_includes: list[str] = []
+ second_level_includes: list[str] = []
+ for included_file in included_files:
+ incs, multflt, mults = _scan_deck_file(dck, included_file)
+ first_level_includes.extend(incs)
+ maindeckmultflt = maindeckmultflt or multflt
+ multipliers_values = multipliers_values | mults
+ for included_file in first_level_includes:
+ incs, multflt, mults = _scan_deck_file(dck, included_file)
+ second_level_includes.extend(incs)
+ maindeckmultflt = maindeckmultflt or multflt
+ multipliers_values = multipliers_values | mults
+ for included_file in second_level_includes:
+ incs, multflt, mults = _scan_deck_file(dck, included_file)
+ maindeckmultflt = maindeckmultflt or multflt
+ multipliers_values = multipliers_values | mults
+ return maindeckmultflt, multipliers_values
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,E1102
+
+"""Refine a corner-point grid and its reservoir properties."""
+
+import argparse
+import sys
+from contextlib import nullcontext
+from dataclasses import dataclass
+
+import numpy as np
+from alive_progress import alive_bar
+from numpy.typing import NDArray
+
+from pycopm.config.config import ConfigViaDeck
+from pycopm.utils.files_writer import write_grid, write_property_inc
+from pycopm.utils.input_values import parse_axis_modifications
+
+
+
+[docs]
+@dataclass(slots=True)
+class RefinementMaps:
+ """Store axis refinement values and subdivision counts."""
+
+ #: Number of additional cells created from each original x interval.
+ x: NDArray
+
+ #: Number of additional cells created from each original y interval.
+ y: NDArray
+
+ #: Number of additional cells created from each original z interval.
+ z: NDArray
+
+ #: Number of refined cells generated from each original cell, flattened in
+ #: ``(z, y, x)`` order.
+ refined_cell_counts: NDArray
+
+
+
+
+[docs]
+def create_refinement_maps(
+ dck: ConfigViaDeck, cmdargs: argparse.Namespace
+) -> RefinementMaps:
+ """Create axis refinement maps and update output dimensions.
+
+ A refinement value of ``n`` divides an original interval into ``n + 1``
+ intervals.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose output dimensions are updated.
+ cmdargs
+ Command arguments containing ``refinement``, ``x_refinement``,
+ ``y_refinement``, and ``z_refinement``.
+
+ Returns
+ -------
+ RefinementMaps
+ Axis values and the number of subdivisions per original cell."""
+ cijk, refs = parse_axis_modifications(
+ cmdargs.refinement,
+ [
+ cmdargs.x_refinement,
+ cmdargs.y_refinement,
+ cmdargs.z_refinement,
+ ],
+ )
+ values = []
+ for direction_index, direction in enumerate(("x", "y", "z")):
+ original_size = getattr(dck, f"original_n{direction}")
+ if len(cijk) > 2:
+ refinement_values = np.full(
+ original_size, int(cijk[direction_index]), dtype=int
+ )
+ elif len(refs[direction_index]) > 0:
+ refinement_values = np.asarray(refs[direction_index], dtype=int)
+ else:
+ refinement_values = np.zeros(original_size, dtype=int)
+ values.append(refinement_values)
+ setattr(
+ dck,
+ f"output_n{direction}",
+ original_size + int(np.sum(refinement_values)),
+ )
+ x_repetitions = values[0] + 1
+ y_repetitions = values[1] + 1
+ z_repetitions = values[2] + 1
+ refined_cell_counts = (
+ (
+ z_repetitions[:, None, None]
+ * y_repetitions[None, :, None]
+ * x_repetitions[None, None, :]
+ )
+ .ravel()
+ .astype(float)
+ )
+ return RefinementMaps(
+ x=values[0], y=values[1], z=values[2], refined_cell_counts=refined_cell_counts
+ )
+
+
+
+
+[docs]
+def refine_properties(
+ dck: ConfigViaDeck, refinement: RefinementMaps, modified_deck: list[str]
+) -> list[str]:
+ """Map reservoir properties onto the refined grid.
+
+ Properties are copied to generated cells. ``PORV`` is divided equally among
+ them to preserve each original cell's pore volume.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing source properties and output dimensions.
+ refinement
+ Refinement maps created by :func:`create_refinement_maps`.
+ modified_deck
+ Deck lines updated with generated property includes.
+
+ Returns
+ -------
+ generated_files
+ Names of the written include files."""
+ generated_files = []
+ number_values = dck.output_nx * dck.output_ny * dck.output_nz
+ property_names = (
+ dck.props_keywords
+ + dck.regions_keywords
+ + ["porv"]
+ + dck.grids_keywords
+ + dck.solution_keywords
+ )
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_status = alive_bar(len(property_names), bar="fish")
+ else:
+ bar_status = nullcontext()
+ x_repetitions = np.asarray(refinement.x, dtype=np.intp) + 1
+ y_repetitions = np.asarray(refinement.y, dtype=np.intp) + 1
+ z_repetitions = np.asarray(refinement.z, dtype=np.intp) + 1
+ with bar_status as bar_animation:
+ for property_name in property_names:
+ if show_progress:
+ bar_animation()
+ if property_name == "porv":
+ values = np.divide(
+ np.asarray(dck.init_file[property_name.upper()]),
+ refinement.refined_cell_counts,
+ )
+ else:
+ values = np.zeros(dck.original_cell_count)
+ if property_name in dck.solution_keywords:
+ values[dck.original_active_cell_mask] = dck.restart_file[
+ property_name.upper(), 0
+ ]
+ else:
+ values[dck.original_active_cell_mask] = dck.init_file[
+ property_name.upper()
+ ]
+ output_dtype = int if "num" in property_name else float
+ coarse_values = values.reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ refined_values = (
+ np.repeat(
+ np.repeat(
+ np.repeat(coarse_values, x_repetitions, axis=2),
+ y_repetitions,
+ axis=1,
+ ),
+ z_repetitions,
+ axis=0,
+ )
+ .reshape(number_values)
+ .astype(output_dtype, copy=False)
+ )
+ if property_name == "porv":
+ dck.output_actnum = (refined_values > 0).astype(int)
+ generated_files.append(f"{dck.include_prefix}{property_name.upper()}.INC")
+ write_property_inc(
+ dck,
+ property_name,
+ refined_values,
+ number_values,
+ modified_deck,
+ True,
+ )
+ return generated_files
+
+
+
+
+[docs]
+def create_coord_axis_map(
+ refinement_values: NDArray,
+) -> tuple[NDArray, NDArray]:
+ """Create interpolation data for one COORD axis.
+
+ Parameters
+ ----------
+ refinement_values
+ Number of additional cells in each original interval.
+
+ Returns
+ -------
+ source_indices, fractions
+ Original intervals and relative positions of refined grid points."""
+ interval_counts = refinement_values + 1
+ source_indices = np.repeat(
+ np.arange(refinement_values.size, dtype=np.intp),
+ interval_counts,
+ )
+ fraction_blocks = [
+ np.arange(interval_count, dtype=float) / interval_count
+ for interval_count in interval_counts
+ ]
+ source_indices = np.concatenate(
+ (
+ source_indices,
+ np.asarray([refinement_values.size - 1], dtype=np.intp),
+ )
+ )
+ fractions = np.concatenate(
+ (
+ *fraction_blocks,
+ np.asarray([1.0]),
+ )
+ )
+ return source_indices, fractions
+
+
+
+
+[docs]
+def create_zcorn_axis_map(
+ refinement_values: NDArray,
+) -> tuple[NDArray, NDArray]:
+ """Create interpolation data for one ZCORN axis.
+
+ Parameters
+ ----------
+ refinement_values
+ Number of additional cells in each original interval.
+
+ Returns
+ -------
+ source_indices, fractions
+ Original intervals and relative corner positions in ZCORN order."""
+ interval_counts = refinement_values + 1
+ source_indices = np.repeat(
+ np.arange(refinement_values.size, dtype=np.intp),
+ 2 * interval_counts,
+ )
+ fraction_blocks = [
+ np.repeat(
+ np.arange(interval_count + 1, dtype=float) / interval_count,
+ 2,
+ )[1:-1]
+ for interval_count in interval_counts
+ ]
+ fractions = np.concatenate(fraction_blocks)
+ return source_indices, fractions
+
+
+
+
+[docs]
+def refine_zcorn_surface(
+ source_surface: NDArray,
+ destination_surface: NDArray,
+ original_nx: int,
+ original_ny: int,
+ output_nx: int,
+ output_ny: int,
+ zcorn_x_indices: NDArray,
+ zcorn_y_indices: NDArray,
+ zcorn_x_fractions: NDArray,
+ zcorn_y_fractions: NDArray,
+) -> None:
+ """Interpolate one ZCORN surface onto the refined horizontal grid.
+
+ Parameters
+ ----------
+ source_surface
+ Flattened input surface with ``4 * original_nx * original_ny`` values.
+ destination_surface
+ Preallocated output with ``4 * output_nx * output_ny`` values, modified
+ in place.
+ original_nx, original_ny
+ Original horizontal grid dimensions.
+ output_nx, output_ny
+ Refined horizontal grid dimensions.
+ zcorn_x_indices, zcorn_y_indices
+ Source interval indices for refined corners.
+ zcorn_x_fractions, zcorn_y_fractions
+ Relative interpolation positions within source intervals."""
+ source_values = source_surface.reshape(
+ original_ny,
+ 2,
+ original_nx,
+ 2,
+ )
+ x_start_values = source_values[:, :, zcorn_x_indices, 0]
+ x_value_difference = source_values[:, :, zcorn_x_indices, 1] - x_start_values
+ x_refined_values = (
+ x_start_values + zcorn_x_fractions[None, None, :] * x_value_difference
+ )
+ y_start_values = x_refined_values[zcorn_y_indices, 0, :]
+ y_value_difference = x_refined_values[zcorn_y_indices, 1, :] - y_start_values
+ destination_values = destination_surface.reshape(
+ 2 * output_ny,
+ 2 * output_nx,
+ )
+ np.multiply(
+ y_value_difference,
+ zcorn_y_fractions[:, None],
+ out=destination_values,
+ )
+ destination_values += y_start_values
+
+
+
+
+[docs]
+def refine_grid(dck: ConfigViaDeck, refinement: RefinementMaps) -> None:
+ """Create and write the refined corner-point grid.
+
+ ``COORD`` and ``ZCORN`` values are linearly interpolated along the refined
+ axes.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing original geometry and grid dimensions.
+ refinement
+ Axis refinement maps."""
+ original_zcorn = np.asarray(dck.egrid_file["ZCORN"], dtype=float)
+ original_coord = np.asarray(dck.egrid_file["COORD"], dtype=float)
+ x_refinement = np.asarray(refinement.x, dtype=np.intp)
+ y_refinement = np.asarray(refinement.y, dtype=np.intp)
+ z_refinement = np.asarray(refinement.z, dtype=np.intp)
+
+ coord_x_indices, coord_x_fractions = create_coord_axis_map(x_refinement)
+ coord_y_indices, coord_y_fractions = create_coord_axis_map(y_refinement)
+ source_coord = original_coord.reshape(
+ dck.original_ny + 1,
+ dck.original_nx + 1,
+ 6,
+ )
+ x_start_coord = source_coord[:, coord_x_indices, :]
+ x_coord_difference = source_coord[:, coord_x_indices + 1, :] - x_start_coord
+ x_refined_coord = (
+ x_start_coord + coord_x_fractions[None, :, None] * x_coord_difference
+ )
+ y_start_coord = x_refined_coord[coord_y_indices, :, :]
+ y_coord_difference = x_refined_coord[coord_y_indices + 1, :, :] - y_start_coord
+ refined_coord = (
+ y_start_coord + coord_y_fractions[:, None, None] * y_coord_difference
+ )
+ cr = refined_coord.ravel()
+
+ zcorn_x_indices, zcorn_x_fractions = create_zcorn_axis_map(x_refinement)
+ zcorn_y_indices, zcorn_y_fractions = create_zcorn_axis_map(y_refinement)
+ source_surface_size = 4 * dck.original_nx * dck.original_ny
+ refined_surface_size = 4 * dck.output_nx * dck.output_ny
+ refined_zcorn_size = 8 * dck.output_nx * dck.output_ny * dck.output_nz
+ source_surfaces = original_zcorn.reshape(
+ 2 * dck.original_nz,
+ source_surface_size,
+ )
+ zc = np.empty(refined_zcorn_size, dtype=float)
+
+ refined_top_surface = np.empty(
+ refined_surface_size,
+ dtype=float,
+ )
+ refined_bottom_surface = np.empty(
+ refined_surface_size,
+ dtype=float,
+ )
+ output_index = 0
+
+ original_nx = dck.original_nx
+ original_ny = dck.original_ny
+ output_nx = dck.output_nx
+ output_ny = dck.output_ny
+ for layer_index, refinement_value in enumerate(z_refinement):
+ refinement_count = int(refinement_value) + 1
+ refine_zcorn_surface(
+ source_surfaces[2 * layer_index],
+ refined_top_surface,
+ original_nx,
+ original_ny,
+ output_nx,
+ output_ny,
+ zcorn_x_indices,
+ zcorn_y_indices,
+ zcorn_x_fractions,
+ zcorn_y_fractions,
+ )
+ refine_zcorn_surface(
+ source_surfaces[2 * layer_index + 1],
+ refined_bottom_surface,
+ original_nx,
+ original_ny,
+ output_nx,
+ output_ny,
+ zcorn_x_indices,
+ zcorn_y_indices,
+ zcorn_x_fractions,
+ zcorn_y_fractions,
+ )
+ surface_difference = refined_bottom_surface - refined_top_surface
+ vertical_fractions = np.repeat(
+ np.arange(
+ refinement_count + 1,
+ dtype=float,
+ )
+ / refinement_count,
+ 2,
+ )[1:-1]
+
+ for vertical_fraction in vertical_fractions:
+ output_surface = zc[output_index : output_index + refined_surface_size]
+ if vertical_fraction == 0.0:
+ np.copyto(output_surface, refined_top_surface)
+ elif vertical_fraction == 1.0:
+ np.copyto(output_surface, refined_bottom_surface)
+ else:
+ np.multiply(
+ surface_difference,
+ vertical_fraction,
+ out=output_surface,
+ )
+ output_surface += refined_top_surface
+ output_index += refined_surface_size
+
+ write_grid(dck, cr, zc, False)
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0914
+
+"""Run TOML-based simulation studies and generate postprocessing plots."""
+
+import shlex
+import stat
+import subprocess
+import sys
+from pathlib import Path
+
+from mako.template import Template
+
+from pycopm.config.config import ConfigViaTOML
+from pycopm.utils.terminal import pycopm_info, pycopm_success
+
+
+
+[docs]
+def run_simulations(cfg: ConfigViaTOML) -> None:
+ """Run the configured OPM Flow or ERT workflow.
+
+ Job scripts are copied to the output project and made executable before a
+ single realization or ERT study is started.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration containing execution mode and commands."""
+ project_path = Path(cfg.output_directory)
+ source_jobs = Path(cfg.resource_directory) / "jobs"
+ target_jobs = project_path / "jobs"
+ target_jobs.mkdir(parents=True, exist_ok=True)
+ subprocess.run(
+ ["cp", "-a", f"{source_jobs}/.", f"{target_jobs}/."],
+ check=True,
+ )
+ for filename in (
+ "PERMX_eval",
+ "PERMY_eval",
+ "PERMZ_eval",
+ "table_eval",
+ "time_eval",
+ "flow_eval",
+ ):
+ script_path = target_jobs / f"{filename}.py"
+ if script_path.exists():
+ script_path.chmod(script_path.stat().st_mode | stat.S_IXUSR)
+ if cfg.execution_mode == "single-run":
+ subprocess.run(
+ ["ert", "test_run", "ert.ert"],
+ cwd=project_path,
+ check=True,
+ )
+ simulation_path = (
+ project_path / "output" / "simulations" / "realisation-0" / "iter-0"
+ )
+ subprocess.run(
+ [
+ *shlex.split(str(cfg.flow_command)),
+ str(simulation_path / f"{cfg.reference_case_name}_COARSER.DATA"),
+ f"--output-dir={simulation_path}",
+ ],
+ cwd=project_path,
+ check=True,
+ )
+ elif cfg.execution_mode == "ert":
+ subprocess.run(
+ ["ert", *shlex.split(str(cfg.ert_arguments)), "ert.ert"],
+ cwd=project_path,
+ check=True,
+ )
+ pycopm_success("the results have been written to ", str(project_path), [])
+
+
+
+
+[docs]
+def generate_postprocessing_plots(
+ cfg: ConfigViaTOML, elapsed_seconds: float, number_tables: int
+) -> None:
+ """Render and execute the postprocessing script.
+
+ Parameters
+ ----------
+ cfg
+ TOML configuration and plotting settings. ``let_parameters`` is sorted in
+ place before rendering.
+ elapsed_seconds
+ Elapsed preprocessing and simulation time.
+ number_tables
+ Number of generated saturation-function tables."""
+ project_path = Path(cfg.output_directory)
+ simulations_path = project_path / "output" / "simulations"
+ ensemble_size = len(next(simulations_path.walk())[1])
+ number_iterations = 1
+ for realisation_index in range(ensemble_size):
+ realisation_path = simulations_path / f"realisation-{realisation_index}"
+ number_iterations = max(
+ number_iterations, len(next(realisation_path.walk())[1])
+ )
+ cfg.let_parameters = sorted(cfg.let_parameters, key=lambda item: item[0])
+ template = Template(
+ filename=str(
+ Path(cfg.resource_directory)
+ / "template_scripts"
+ / "common"
+ / "plot_post.mako"
+ )
+ )
+ rendered_template = template.render(
+ output_directory=cfg.output_directory,
+ resource_directory=cfg.resource_directory,
+ let_parameters=cfg.let_parameters,
+ history_matching_end_date=cfg.history_matching_end_date,
+ reference_case_name=cfg.reference_case_name,
+ model_name=cfg.model_name,
+ observation_relative_errors=cfg.observation_relative_errors,
+ observation_minimum_errors=cfg.observation_minimum_errors,
+ cleanup_file_suffixes=cfg.cleanup_file_suffixes,
+ number_tables=number_tables,
+ elapsed_seconds=elapsed_seconds,
+ number_iterations=number_iterations,
+ ensemble_size=ensemble_size,
+ )
+ plotting_path = project_path / "jobs" / "plotting.py"
+ plotting_path.write_text(rendered_template, encoding="utf8")
+ pycopm_info("running the postprocessing methods, please wait...")
+ subprocess.run(
+ [sys.executable, str(plotting_path)],
+ cwd=project_path,
+ check=True,
+ )
+
+
+# SPDX-FileCopyrightText: 2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+
+"""Format command-line messages for pycopm.
+
+ANSI colors are applied when supported by the selected stream. The module
+provides consistent formatting for invalid, accepted, deprecated, and
+informational values, plus fatal errors, warnings, tips, progress messages, and
+generated-file reports."""
+
+import os
+import sys
+from typing import NoReturn
+
+ANSI_BOLD_RED = "1;31"
+ANSI_BOLD_YELLOW = "1;33"
+ANSI_BOLD_GREEN = "1;32"
+ANSI_BOLD_BLUE = "1;34"
+ANSI_BOLD_MAGENTA = "1;35"
+ANSI_YELLOW = "1;33"
+ANSI_GREEN = "1;32"
+ANSI_CYAN = "36"
+ANSI_RED = "31"
+ANSI_BLUE = "1;34"
+
+
+
+[docs]
+def _supports_color(stream: object = sys.stderr) -> bool:
+ """Check whether an output stream supports ANSI colors.
+
+ Parameters
+ ----------
+ stream : object, optional
+ Output stream used to determine ANSI-color support.
+
+ Returns
+ -------
+ bool
+ Whether ANSI color output is enabled for the stream."""
+ return (
+ hasattr(stream, "isatty")
+ and stream.isatty()
+ and os.environ.get("NO_COLOR") is None
+ and os.environ.get("TERM") != "dumb"
+ )
+
+
+
+
+[docs]
+def _colorize(
+ text: str,
+ code: str,
+ stream: object = sys.stderr,
+) -> str:
+ """Wrap text in an ANSI color sequence when supported.
+
+ Parameters
+ ----------
+ text : str
+ Text to format.
+ code : str
+ ANSI Select Graphic Rendition code.
+ stream : object, optional
+ Output stream used to determine ANSI-color support.
+
+ Returns
+ -------
+ str
+ Colored text, or unchanged text when colors are disabled."""
+ if not _supports_color(stream):
+ return text
+ return f"\033[{code}m{text}\033[0m"
+
+
+
+
+[docs]
+def cli_warning_value(value: str) -> str:
+ """Format a deprecated CLI option or value.
+
+ Parameters
+ ----------
+ value : str
+ Value to inspect or format.
+
+ Returns
+ -------
+ str
+ Quoted value colored as a warning when supported."""
+ return _colorize(repr(value), ANSI_YELLOW)
+
+
+
+
+[docs]
+def cli_correct_value(value: str) -> str:
+ """Format a correct CLI option or value.
+
+ Parameters
+ ----------
+ value : str
+ Value to inspect or format.
+
+ Returns
+ -------
+ str
+ Quoted value colored as a valid alternative when supported."""
+ return _colorize(repr(value), ANSI_GREEN)
+
+
+
+
+[docs]
+def cli_error_value(value: str) -> str:
+ """Format an invalid CLI option or value.
+
+ Parameters
+ ----------
+ value : str
+ Value to inspect or format.
+
+ Returns
+ -------
+ str
+ Quoted value colored as invalid when supported."""
+ return _colorize(repr(value), ANSI_RED)
+
+
+
+
+[docs]
+def cli_info_value(value: str) -> str:
+ """Format an informational CLI option or value.
+
+ Parameters
+ ----------
+ value : str
+ Value to inspect or format.
+
+ Returns
+ -------
+ str
+ Quoted value colored as information when supported."""
+ return _colorize(repr(value), ANSI_BLUE)
+
+
+
+
+[docs]
+def pycopm_error(message: str) -> NoReturn:
+ """Raise a fatal command-line error.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable message to display or append.
+
+ Raises
+ ------
+ SystemExit
+ Always raised with the formatted error message."""
+ label = _colorize("error", ANSI_BOLD_RED)
+ raise SystemExit(f"{pycopm_name()}: {label}: {message}")
+
+
+
+
+[docs]
+def pycopm_warning(message: str) -> None:
+ """Display a non-fatal command-line warning.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable message to display or append."""
+ label = _colorize("warning", ANSI_BOLD_YELLOW)
+ print(f"{pycopm_name()}: {label}: {message}", file=sys.stderr)
+
+
+
+
+[docs]
+def pycopm_info(message: str) -> None:
+ """Display an informational command-line message.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable message to display or append."""
+ label = _colorize("info", ANSI_BOLD_BLUE, sys.stdout)
+ print(f"{pycopm_name()}: {label}: {message}")
+
+
+
+
+[docs]
+def pycopm_tip(message: str) -> None:
+ """Display a command-line suggestion.
+
+ Parameters
+ ----------
+ message : str
+ Human-readable message to display or append."""
+ label = _colorize("tip", ANSI_BOLD_MAGENTA, sys.stdout)
+ print(f"{pycopm_name(sys.stdout)}: {label}: {message}")
+
+
+
+
+[docs]
+def pycopm_success(msg: str, output_dir: str, filenames: list[str]) -> None:
+ """Display the generated output location and filenames.
+
+ Parameters
+ ----------
+ msg : str
+ Optional success text printed before the output location.
+ output_dir : str
+ Directory containing the generated files.
+ filenames : list[str]
+ Generated filenames to report."""
+ label = _colorize("success", ANSI_BOLD_GREEN, sys.stdout)
+ if not filenames:
+ print(f"{pycopm_name()}: {label}: {msg}{output_dir}")
+ elif len(filenames) == 1:
+ print(f"{pycopm_name()}: {label}: {msg}{output_dir}/{filenames[0]}")
+ elif len(filenames) <= 5:
+ print(f"{pycopm_name()}: {label}{msg}")
+ print(f" Output directory: {output_dir}")
+ print(f" Files ({len(filenames)}): {', '.join(filenames)}")
+ else:
+ print(f"{pycopm_name()}: {label}{msg}")
+ print(f" Output directory: {output_dir}")
+ print(f" Files ({len(filenames)}):")
+ for filename in filenames:
+ print(f" - {filename}")
+
+
+
+
+[docs]
+def pycopm_name(stream: object = sys.stderr) -> str:
+ """Format the pycopm program name.
+
+ Parameters
+ ----------
+ stream : object, optional
+ Output stream used to determine ANSI-color support.
+
+ Returns
+ -------
+ str
+ Formatted program name."""
+ characters = [("pycopm", "1")]
+ return "".join(
+ _colorize(character, color, stream) for character, color in characters
+ )
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,E1102
+
+"""Transform corner-point grid coordinates and rewrite associated properties."""
+
+import sys
+from contextlib import nullcontext
+
+import numpy as np
+from alive_progress import alive_bar
+
+from pycopm.config.config import ConfigViaDeck
+from pycopm.utils.files_writer import write_grid, write_property_inc
+
+
+
+[docs]
+def transform_grid(dck: ConfigViaDeck) -> None:
+ """Apply the configured transformation to the corner-point grid.
+
+ Supported specifications are ``translate [x,y,z]``, ``scale [x,y,z]``, and
+ ``rotatexy``, ``rotatexz``, or ``rotateyz`` followed by an angle in degrees.
+ Rotations are performed about the coordinate-system origin.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing ``grid_transformation`` and source geometry."""
+ transformation = dck.grid_transformation.split()
+ transformation_name = transformation[0]
+
+ original_zcorn = np.asarray(dck.egrid_file["ZCORN"], dtype=float)
+ original_coord = np.asarray(dck.egrid_file["COORD"], dtype=float)
+ transformed_coord = original_coord.reshape(-1, 2, 3).copy()
+
+ if transformation_name in ("translate", "scale"):
+ transformation_values = np.fromstring(
+ transformation[1].strip("()[]"), sep=",", dtype=float
+ )
+
+ if transformation_name == "translate":
+ transformed_coord += transformation_values
+ zc = original_zcorn + transformation_values[2]
+ else:
+ transformed_coord *= transformation_values
+ zc = original_zcorn * transformation_values[2]
+
+ else:
+ angle = np.deg2rad(float(transformation[1]))
+ cosine = np.cos(angle)
+ sine = np.sin(angle)
+
+ if transformation_name == "rotatexy":
+ xy_values = transformed_coord[:, :, :2].copy()
+ transformed_coord[:, :, 0] = (
+ xy_values[:, :, 0] * cosine - xy_values[:, :, 1] * sine
+ )
+ transformed_coord[:, :, 1] = (
+ xy_values[:, :, 1] * cosine + xy_values[:, :, 0] * sine
+ )
+ zc = original_zcorn.copy()
+
+ else:
+ coordinate_axis = 0 if transformation_name == "rotatexz" else 1
+ corner_pairs = np.asarray(((0, 1), (2, 3), (4, 5), (6, 7)))
+ horizontal_coordinates = np.empty(original_zcorn.size, dtype=float)
+ horizontal_index = 0
+
+ for layer_index in range(dck.original_nz):
+ layer_coordinates = np.asarray(
+ [
+ [
+ dck.grid_model.xyz_from_ijk(
+ column_index, row_index, layer_index
+ )[coordinate_axis]
+ for column_index in range(dck.original_nx)
+ ]
+ for row_index in range(dck.original_ny)
+ ],
+ dtype=float,
+ )
+ coordinate_values = (
+ layer_coordinates[:, :, corner_pairs]
+ .transpose(2, 0, 1, 3)
+ .reshape(-1)
+ )
+ next_index = horizontal_index + coordinate_values.size
+ horizontal_coordinates[horizontal_index:next_index] = coordinate_values
+ horizontal_index = next_index
+
+ horizontal_coordinates = horizontal_coordinates.reshape(
+ original_zcorn.shape
+ )
+
+ if transformation_name == "rotatexz":
+ xz_values = transformed_coord[:, :, (0, 2)].copy()
+ transformed_coord[:, :, 0] = (
+ xz_values[:, :, 0] * cosine + xz_values[:, :, 1] * sine
+ )
+ transformed_coord[:, :, 2] = (
+ xz_values[:, :, 1] * cosine - xz_values[:, :, 0] * sine
+ )
+ zc = original_zcorn * cosine - horizontal_coordinates * sine
+ else:
+ yz_values = transformed_coord[:, :, (1, 2)].copy()
+ transformed_coord[:, :, 1] = (
+ yz_values[:, :, 0] * cosine - yz_values[:, :, 1] * sine
+ )
+ transformed_coord[:, :, 2] = (
+ yz_values[:, :, 1] * cosine + yz_values[:, :, 0] * sine
+ )
+ zc = original_zcorn * cosine + horizontal_coordinates * sine
+
+ cr = transformed_coord.ravel()
+ write_grid(dck, cr, zc, False)
+
+
+
+
+[docs]
+def transform_properties(dck: ConfigViaDeck, modified_deck: list[str]) -> list[str]:
+ """Rewrite reservoir properties for a transformed grid.
+
+ Property values are unchanged because transformations modify only geometry.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing source properties and output dimensions.
+ modified_deck
+ Deck lines updated with generated property includes.
+
+ Returns
+ -------
+ generated_files
+ Names of the written include files."""
+ generated_files = []
+ property_names = (
+ dck.props_keywords
+ + dck.regions_keywords
+ + dck.grids_keywords
+ + dck.solution_keywords
+ + ["porv"]
+ )
+ number_values = dck.output_nx * dck.output_ny * dck.output_nz
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_progress = alive_bar(len(property_names), bar="fish")
+ else:
+ bar_progress = nullcontext()
+ with bar_progress as bar_animation:
+ for property_name in property_names:
+ if show_progress:
+ bar_animation()
+ values = np.zeros(dck.original_cell_count)
+ if property_name in dck.solution_keywords:
+ values[dck.original_active_cell_mask] = dck.restart_file[
+ property_name.upper(), 0
+ ]
+ elif property_name == "porv":
+ values = np.asarray(dck.init_file[property_name.upper()])
+ else:
+ values[dck.original_active_cell_mask] = dck.init_file[
+ property_name.upper()
+ ]
+ write_property_inc(
+ dck,
+ property_name,
+ values,
+ number_values,
+ modified_deck,
+ True,
+ )
+ generated_files.append(f"{dck.include_prefix}{property_name.upper()}.INC")
+ return generated_files
+
+
+# SPDX-FileCopyrightText: 2024-2026 NORCE Research AS
+# SPDX-License-Identifier: GPL-3.0
+# pylint: disable=R0902,R0912,R0913,R0914,R0915,C0302,R0917,R1702,R0916,R0911,E1102
+
+"""Extract submodels and map pore volume from outside their boundaries."""
+
+import csv
+import sys
+from contextlib import nullcontext
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+from alive_progress import alive_bar
+from numpy.typing import NDArray
+from shapely import Polygon, contains_xy, prepare
+
+from pycopm.config.config import ConfigViaDeck
+from pycopm.utils.files_writer import write_grid, write_property_inc
+
+
+
+[docs]
+@dataclass(slots=True)
+class VicinityMaps:
+ """Store a vicinity selection, bounds, and pore-volume mappings.
+
+ Global and per-layer bounds are one-based and inclusive.
+ """
+
+ #: Selected well name, or an empty string for polygon and region
+ #: selections.
+ selector: str
+
+ #: Well-vicinity shape: ``box``, ``diamond``, or ``diamondxy``. ``None`` is
+ #: used for polygon and region selections.
+ shape: str | None
+
+ #: Boolean mask identifying selected original-grid cells, flattened in
+ #: ``(z, y, x)`` order.
+ cell_mask: NDArray
+
+ #: Minimum selected i index across all layers.
+ min_i: int
+
+ #: Maximum selected i index across all layers.
+ max_i: int
+
+ #: Minimum selected j index across all layers.
+ min_j: int
+
+ #: Maximum selected j index across all layers.
+ max_j: int
+
+ #: Minimum selected k index.
+ min_k: int
+
+ #: Maximum selected k index.
+ max_k: int
+
+ #: Minimum selected i index in each original layer.
+ layer_min_i: NDArray
+
+ #: Maximum selected i index in each original layer.
+ layer_max_i: NDArray
+
+ #: Minimum selected j index in each original layer.
+ layer_min_j: NDArray
+
+ #: Maximum selected j index in each original layer.
+ layer_max_j: NDArray
+
+ #: Total pore volume of selected active cells in each original layer.
+ layer_selected_porv: NDArray
+
+ #: Total pore volume outside the selection in each original layer.
+ layer_external_porv: NDArray
+
+ #: Zero-based completion coordinates in ``[i, j, k]`` order for the
+ #: selected well.
+ well_cells: list[list[int]]
+
+ #: Zero-based output-grid indices of selected well cells. These cells are
+ #: excluded from boundary source remapping.
+ well_indices: list[int] = field(default_factory=list)
+
+ #: Number of selected active cells in each output layer.
+ active_counts: NDArray = field(default_factory=lambda: np.array([], dtype=int))
+
+ #: Original-grid source index assigned to each output boundary cell during
+ #: pore-volume correction. Zero denotes no assigned source.
+ source_indices: NDArray = field(default_factory=lambda: np.array([], dtype=int))
+
+
+
+
+[docs]
+@dataclass(slots=True)
+class _BoundaryMapping:
+ """Store pore-volume mapping results for one submodel boundary."""
+
+ #: Unassigned pore volume collected along the boundary.
+ pore_volume: float
+
+ #: Number of active cells receiving pore volume from the boundary.
+ active_count: int
+
+ #: Distance from the geometric boundary to each receiving cell.
+ offsets: NDArray
+
+
+
+
+[docs]
+def _submodel_index(dck: ConfigViaDeck, column: int, row: int, layer: int) -> int:
+ return column + row * dck.output_nx + layer * dck.output_nx * dck.output_ny
+
+
+
+
+[docs]
+def _original_index(dck: ConfigViaDeck, column: int, row: int, layer: int) -> int:
+ return column + row * dck.original_nx + layer * dck.original_nx * dck.original_ny
+
+
+
+
+[docs]
+def _add_or_collect_porv(
+ dck: ConfigViaDeck, submodel_index: int, pore_volume: float
+) -> float:
+ if dck.pore_volume_correction in (1, 2):
+ dck.output_porv[submodel_index] += pore_volume
+ return 0.0
+ return pore_volume
+
+
+
+
+[docs]
+def create_vicinity_maps(dck: ConfigViaDeck) -> VicinityMaps:
+ """Select submodel cells and calculate their bounds.
+
+ Selections can use region values, an xy polygon, or a well-centred ``box``,
+ ``diamond``, or ``diamondxy`` neighbourhood.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing the vicinity specification and source grid.
+
+ Returns
+ -------
+ VicinityMaps
+ Selection mask, bounds, well cells, and per-layer pore-volume totals."""
+ vicinity_options = dck.vicinity_specification.split()
+ selector = vicinity_options[0]
+ is_selector_well = False
+ shape: str | None = None
+ total_cells = dck.original_nx * dck.original_ny * dck.original_nz
+ cells_per_layer = dck.original_nx * dck.original_ny
+ well_cells = []
+ if selector.upper() == "XYPOLYGON":
+ cell_centers = np.empty((total_cells, 3), dtype=float)
+ for global_index in range(total_cells):
+ layer_index, layer_offset = divmod(global_index, cells_per_layer)
+ row_index, column_index = divmod(layer_offset, dck.original_nx)
+ cell_coordinates = np.asarray(
+ dck.grid_model.xyz_from_ijk(column_index, row_index, layer_index, True),
+ dtype=float,
+ )
+ cell_centers[global_index] = np.mean(cell_coordinates, axis=1)
+ polygon_coordinates = np.asarray(
+ [
+ (
+ float(coordinate.split(",")[0][1:]),
+ float(coordinate.split(",")[1][:-1]),
+ )
+ for coordinate in vicinity_options[1:]
+ ],
+ dtype=float,
+ )
+ grid_minimum = np.minimum(
+ np.min(cell_centers[:, :2], axis=0),
+ np.min(polygon_coordinates, axis=0),
+ )
+ grid_maximum = np.maximum(
+ np.max(cell_centers[:, :2], axis=0),
+ np.max(polygon_coordinates, axis=0),
+ )
+ coordinate_range = grid_maximum - grid_minimum
+ normalized_centers = (cell_centers[:, :2] - grid_minimum) / coordinate_range
+ normalized_polygon = (polygon_coordinates - grid_minimum) / coordinate_range
+ polygon = Polygon(normalized_polygon)
+ prepare(polygon)
+ cell_mask = contains_xy(
+ polygon, normalized_centers[:, 0], normalized_centers[:, 1]
+ )
+ elif len(vicinity_options) > 2:
+ is_selector_well = True
+ shape = vicinity_options[1].lower()
+ well_cells = _get_well_completions_for_vicinity(dck, selector)
+ cell_mask = np.zeros(total_cells, dtype=bool)
+ well_locations = np.asarray(well_cells, dtype=np.intp).reshape(-1, 3)
+ if shape == "diamond":
+ interval = int(vicinity_options[2])
+ offset_range = np.arange(-interval, interval + 1, dtype=np.intp)
+ layer_offsets, row_offsets, column_offsets = np.meshgrid(
+ offset_range, offset_range, offset_range, indexing="ij"
+ )
+ offset_mask = (
+ (np.abs(row_offsets - column_offsets - layer_offsets) <= interval)
+ & (np.abs(column_offsets + row_offsets - layer_offsets) <= interval)
+ & (np.abs(row_offsets - column_offsets + layer_offsets) <= interval)
+ & (np.abs(column_offsets + row_offsets + layer_offsets) <= interval)
+ )
+ vicinity_offsets = np.column_stack(
+ (
+ column_offsets[offset_mask],
+ row_offsets[offset_mask],
+ layer_offsets[offset_mask],
+ )
+ )
+ for well_location in well_locations:
+ selected_locations = well_location + vicinity_offsets
+ location_mask = (
+ (selected_locations[:, 0] >= 0)
+ & (selected_locations[:, 0] < dck.original_nx)
+ & (selected_locations[:, 1] >= 0)
+ & (selected_locations[:, 1] < dck.original_ny)
+ & (selected_locations[:, 2] >= 0)
+ & (selected_locations[:, 2] < dck.original_nz)
+ )
+ selected_locations = selected_locations[location_mask]
+ global_indices = (
+ selected_locations[:, 0]
+ + selected_locations[:, 1] * dck.original_nx
+ + selected_locations[:, 2] * cells_per_layer
+ )
+ cell_mask[global_indices] = True
+ elif shape == "diamondxy":
+ interval = int(vicinity_options[2])
+ offset_range = np.arange(-interval, interval + 1, dtype=np.intp)
+ row_offsets_xy, column_offsets_xy = np.meshgrid(
+ offset_range, offset_range, indexing="ij"
+ )
+ offset_mask = (np.abs(row_offsets_xy - column_offsets_xy) <= interval) & (
+ np.abs(column_offsets_xy + row_offsets_xy) <= interval
+ )
+ horizontal_offsets = np.column_stack(
+ (column_offsets_xy[offset_mask], row_offsets_xy[offset_mask])
+ )
+ all_layer_offsets = (
+ np.arange(dck.original_nz, dtype=np.intp)[:, None] * cells_per_layer
+ )
+ for well_location in well_locations:
+ selected_columns = well_location[0] + horizontal_offsets[:, 0]
+ selected_rows = well_location[1] + horizontal_offsets[:, 1]
+ location_mask = (
+ (selected_columns >= 0)
+ & (selected_columns < dck.original_nx)
+ & (selected_rows >= 0)
+ & (selected_rows < dck.original_ny)
+ )
+ horizontal_indices = (
+ selected_columns[location_mask]
+ + selected_rows[location_mask] * dck.original_nx
+ )
+ global_indices = (
+ all_layer_offsets + horizontal_indices[None, :]
+ ).ravel()
+ cell_mask[global_indices] = True
+ else:
+ intervals = np.asarray(
+ [
+ (
+ int(interval.split(",")[0][1:]),
+ int(interval.split(",")[1][:-1]),
+ )
+ for interval in vicinity_options[2:]
+ ],
+ dtype=int,
+ )
+ if intervals.shape != (3, 2):
+ raise ValueError(
+ "The vicinity intervals must define x, y, and z ranges."
+ )
+ column_offsets = np.arange(
+ intervals[0, 0], intervals[0, 1] + 1, dtype=np.intp
+ )
+ row_offsets = np.arange(intervals[1, 0], intervals[1, 1] + 1, dtype=np.intp)
+ layer_offsets = np.arange(
+ intervals[2, 0], intervals[2, 1] + 1, dtype=np.intp
+ )
+ layer_offsets, row_offsets, column_offsets = np.meshgrid(
+ layer_offsets, row_offsets, column_offsets, indexing="ij"
+ )
+ vicinity_offsets = np.column_stack(
+ (
+ column_offsets.ravel(),
+ row_offsets.ravel(),
+ layer_offsets.ravel(),
+ )
+ )
+ for well_location in well_locations:
+ selected_locations = well_location + vicinity_offsets
+ location_mask = (
+ (selected_locations[:, 0] >= 0)
+ & (selected_locations[:, 0] < dck.original_nx)
+ & (selected_locations[:, 1] >= 0)
+ & (selected_locations[:, 1] < dck.original_ny)
+ & (selected_locations[:, 2] >= 0)
+ & (selected_locations[:, 2] < dck.original_nz)
+ )
+ selected_locations = selected_locations[location_mask]
+ global_indices = (
+ selected_locations[:, 0]
+ + selected_locations[:, 1] * dck.original_nx
+ + selected_locations[:, 2] * cells_per_layer
+ )
+ cell_mask[global_indices] = True
+ else:
+ selected_values = np.asarray(
+ [int(value) for value in vicinity_options[1].split(",")]
+ )
+ keyword = selector.upper()
+ cell_mask = np.zeros(total_cells, dtype=bool)
+ active_property_values = np.asarray(dck.init_file[keyword])
+ cell_mask[dck.original_active_cell_mask] = np.isin(
+ active_property_values, selected_values
+ )
+ selected_active_cells = np.asarray(cell_mask, dtype=bool) & (
+ np.asarray(dck.original_porv) > 0
+ )
+ selected_indices = np.flatnonzero(selected_active_cells)
+ layer_indices, layer_offsets = np.divmod(selected_indices, cells_per_layer)
+ row_indices, column_indices = np.divmod(layer_offsets, dck.original_nx)
+ min_i = int(np.min(column_indices)) + 1
+ max_i = int(np.max(column_indices)) + 1
+ min_j = int(np.min(row_indices)) + 1
+ max_j = int(np.max(row_indices)) + 1
+ min_k = int(np.min(layer_indices)) + 1
+ max_k = int(np.max(layer_indices)) + 1
+ layer_min_i = np.full(dck.original_nz, dck.original_nx, dtype=int)
+ layer_max_i = np.ones(dck.original_nz, dtype=int)
+ layer_min_j = np.full(dck.original_nz, dck.original_ny, dtype=int)
+ layer_max_j = np.ones(dck.original_nz, dtype=int)
+ np.minimum.at(layer_min_i, layer_indices, column_indices + 1)
+ np.maximum.at(layer_max_i, layer_indices, column_indices + 1)
+ np.minimum.at(layer_min_j, layer_indices, row_indices + 1)
+ np.maximum.at(layer_max_j, layer_indices, row_indices + 1)
+ pore_volumes = np.asarray(dck.original_porv, dtype=float)
+ layer_selected_porv = np.bincount(
+ layer_indices,
+ weights=pore_volumes[selected_indices],
+ minlength=dck.original_nz,
+ )
+ all_layer_indices = np.repeat(np.arange(dck.original_nz), cells_per_layer)
+ layer_external_porv = np.bincount(
+ all_layer_indices,
+ weights=pore_volumes * ~selected_active_cells,
+ minlength=dck.original_nz,
+ )
+ return VicinityMaps(
+ selector=selector if is_selector_well else "",
+ shape=shape,
+ cell_mask=np.asarray(cell_mask, dtype=bool),
+ min_i=min_i,
+ max_i=max_i,
+ min_j=min_j,
+ max_j=max_j,
+ min_k=min_k,
+ max_k=max_k,
+ layer_min_i=layer_min_i,
+ layer_max_i=layer_max_i,
+ layer_min_j=layer_min_j,
+ layer_max_j=layer_max_j,
+ layer_selected_porv=layer_selected_porv,
+ layer_external_porv=layer_external_porv,
+ well_cells=well_cells,
+ )
+
+
+
+
+[docs]
+def map_vicinity_properties(
+ dck: ConfigViaDeck, vicinity: VicinityMaps, modified_deck: list[str]
+) -> list[str]:
+ """Map reservoir properties into the submodel bounding box.
+
+ Cells inside the bounding box but outside the selection are written as
+ inactive. The function updates output pore volume and active cells.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing source properties and output dimensions.
+ vicinity
+ Selection and bounds created by :func:`create_vicinity_maps`.
+ modified_deck
+ Deck lines updated with generated property includes.
+
+ Returns
+ -------
+ generated_files
+ Names of the written include files."""
+ generated_files = []
+ submodel_cells = dck.output_nx * dck.output_ny * dck.output_nz
+ dck.original_active_cell_mask = np.asarray(dck.original_porv) > 0
+ vicinity.active_counts = np.zeros(dck.output_nz)
+ vicinity.source_indices = np.zeros(submodel_cells, dtype=int)
+ property_names = (
+ ["porv"]
+ + dck.solution_keywords
+ + dck.props_keywords
+ + dck.regions_keywords
+ + dck.grids_keywords
+ )
+ column_indices = np.arange(vicinity.min_i - 1, vicinity.max_i, dtype=np.intp)
+ row_indices = np.arange(vicinity.min_j - 1, vicinity.max_j, dtype=np.intp)
+ layer_indices = np.arange(vicinity.min_k - 1, vicinity.max_k, dtype=np.intp)
+ selected_global_indices = (
+ column_indices[None, None, :]
+ + row_indices[None, :, None] * dck.original_nx
+ + layer_indices[:, None, None] * dck.original_nx * dck.original_ny
+ ).ravel()
+ selected_layer_indices = np.broadcast_to(
+ np.arange(dck.output_nz, dtype=np.intp)[:, None, None],
+ (dck.output_nz, dck.output_ny, dck.output_nx),
+ ).ravel()
+ selected_cell_mask = (
+ dck.original_active_cell_mask[selected_global_indices]
+ & np.asarray(vicinity.cell_mask, dtype=bool)[selected_global_indices]
+ )
+ selected_output_indices = np.flatnonzero(selected_cell_mask)
+ effective_global_indices = selected_global_indices[selected_cell_mask]
+ active_global_indices = np.flatnonzero(dck.original_active_cell_mask)
+ active_source_indices = np.searchsorted(
+ active_global_indices, effective_global_indices
+ )
+ vicinity.active_counts[:] = np.bincount(
+ selected_layer_indices[selected_cell_mask],
+ minlength=dck.output_nz,
+ )
+ solution_keywords = set(dck.solution_keywords)
+ show_progress = sys.stdout.isatty()
+ if show_progress:
+ bar_ctx = alive_bar(len(property_names), bar="fish")
+ else:
+ bar_ctx = nullcontext()
+ with bar_ctx as bar_animation:
+ for property_name in property_names:
+ if show_progress:
+ bar_animation()
+ property_dtype = int if "num" in property_name else float
+ values_c = np.zeros(submodel_cells, dtype=property_dtype)
+ if property_name == "porv":
+ source_values = np.asarray(dck.original_porv)
+ values_c[selected_output_indices] = source_values[
+ effective_global_indices
+ ]
+ dck.output_porv = values_c
+ elif property_name in solution_keywords:
+ source_values = np.asarray(dck.restart_file[property_name.upper(), 0])
+ values_c[selected_output_indices] = source_values[active_source_indices]
+ else:
+ source_values = np.asarray(dck.init_file[property_name.upper()])
+ values_c[selected_output_indices] = source_values[active_source_indices]
+ write_property_inc(
+ dck,
+ property_name,
+ values_c,
+ submodel_cells,
+ modified_deck,
+ True,
+ )
+ generated_files.append(f"{dck.include_prefix}{property_name.upper()}.INC")
+ dck.output_actnum = (np.asarray(dck.output_porv) > 0).astype(int)
+ return generated_files
+
+
+
+
+[docs]
+def extract_vicinity_grid(dck: ConfigViaDeck, vicinity: VicinityMaps) -> None:
+ """Extract and write the selected corner-point subgrid.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration containing source geometry and axis mappings.
+ vicinity
+ Inclusive bounds of the selected submodel."""
+ original_zcorn = np.asarray(dck.egrid_file["ZCORN"])
+ original_coord = np.asarray(dck.egrid_file["COORD"])
+ source_coord = original_coord.reshape(dck.original_ny + 1, dck.original_nx + 1, 6)
+ cr = source_coord[
+ vicinity.min_j - 1 : vicinity.max_j + 1,
+ vicinity.min_i - 1 : vicinity.max_i + 1,
+ :,
+ ].ravel()
+ selected_columns = np.asarray(dck.original_to_output_i[1 : dck.original_nx + 1]) > 0
+ selected_rows = np.asarray(dck.original_to_output_j[1 : dck.original_ny + 1]) > 0
+ selected_layers = np.asarray(dck.original_to_output_k[1 : dck.original_nz + 1]) > 0
+ doubled_column_indices = np.flatnonzero(np.repeat(selected_columns, 2))
+ doubled_row_indices = np.flatnonzero(np.repeat(selected_rows, 2))
+ surface_indices = np.flatnonzero(np.repeat(selected_layers, 2))
+ source_zcorn = original_zcorn.reshape(
+ 2 * dck.original_nz,
+ 2 * dck.original_ny,
+ 2 * dck.original_nx,
+ )
+ zc = source_zcorn[
+ np.ix_(
+ surface_indices,
+ doubled_row_indices,
+ doubled_column_indices,
+ )
+ ].ravel()
+ write_grid(dck, cr, zc, False)
+
+
+
+
+[docs]
+def _map_south_boundary(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ layer_index: int,
+ original_layer: int,
+ column_offset: int,
+ row_offset: int,
+ trailing_columns: int,
+) -> _BoundaryMapping:
+ offsets = np.zeros(dck.output_nx, dtype=int)
+ collected_porv = 0.0
+ active_count = 0
+ width = dck.output_nx - column_offset - trailing_columns
+ south_rows = int(vicinity.layer_min_j[original_layer]) - 1
+ original_column_start = int(vicinity.layer_min_i[original_layer]) - 1
+ pore_volume_grid = np.asarray(dck.original_porv).reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ for local_column in range(width):
+ submodel_column = local_column + column_offset
+ submodel_cell = _submodel_index(dck, submodel_column, row_offset, layer_index)
+ original_column = local_column + original_column_start
+ boundary_cell = _original_index(
+ dck, original_column, max(south_rows - 1, 0), original_layer
+ )
+ boundary_porv = float(
+ np.sum(pore_volume_grid[original_layer, :south_rows, original_column])
+ )
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = boundary_cell + dck.original_nx
+ active_count += 1
+ collected_porv += _add_or_collect_porv(dck, submodel_cell, boundary_porv)
+ continue
+ interior_cell = boundary_cell + 1
+ if (
+ interior_cell >= dck.original_porv.size
+ or dck.original_porv[interior_cell] <= 0
+ ):
+ collected_porv += boundary_porv
+ continue
+ for inward_offset in range(dck.output_ny - 1 - row_offset):
+ submodel_cell = _submodel_index(
+ dck,
+ submodel_column,
+ inward_offset + 1 + row_offset,
+ layer_index,
+ )
+ original_row = inward_offset + south_rows
+ original_cell = _original_index(
+ dck, original_column, original_row, original_layer
+ )
+ boundary_porv += 0.5 * dck.original_porv[original_cell]
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = original_cell
+ offsets[local_column] = inward_offset + 1
+ active_count += 1
+ collected_porv += _add_or_collect_porv(
+ dck, submodel_cell, boundary_porv
+ )
+ break
+ if inward_offset == dck.output_ny - 2 - row_offset:
+ collected_porv += boundary_porv
+ return _BoundaryMapping(collected_porv, active_count, offsets)
+
+
+
+
+[docs]
+def _map_north_boundary(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ layer_index: int,
+ original_layer: int,
+ column_offset: int,
+ trailing_rows: int,
+ trailing_columns: int,
+) -> _BoundaryMapping:
+ offsets = np.zeros(dck.output_nx, dtype=int)
+ collected_porv = 0.0
+ active_count = 0
+ width = dck.output_nx - column_offset - trailing_columns
+ north_start = int(vicinity.layer_max_j[original_layer])
+ original_column_start = int(vicinity.layer_min_i[original_layer]) - 1
+ pore_volume_grid = np.asarray(dck.original_porv).reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ for local_column in range(width):
+ submodel_column = local_column + column_offset
+ submodel_cell = _submodel_index(
+ dck, submodel_column, dck.output_ny - 1 - trailing_rows, layer_index
+ )
+ original_column = local_column + original_column_start
+ boundary_porv = float(
+ np.sum(pore_volume_grid[original_layer, north_start:, original_column])
+ )
+ interior_cell = _original_index(
+ dck, original_column, north_start - 1, original_layer
+ )
+ if dck.output_actnum[submodel_cell] > 0:
+ if trailing_rows == 0 and submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = interior_cell
+ active_count += 1
+ collected_porv += _add_or_collect_porv(dck, submodel_cell, boundary_porv)
+ continue
+ if dck.original_porv[interior_cell] <= 0:
+ collected_porv += boundary_porv
+ continue
+ for inward_offset in range(dck.output_ny - 1 - trailing_rows):
+ submodel_row = dck.output_ny - 2 - inward_offset - trailing_rows
+ submodel_cell = _submodel_index(
+ dck, submodel_column, submodel_row, layer_index
+ )
+ original_row = north_start - inward_offset - 1
+ original_cell = _original_index(
+ dck, original_column, original_row, original_layer
+ )
+ boundary_porv += 0.5 * dck.original_porv[original_cell]
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = (
+ original_cell - dck.original_nx
+ )
+ offsets[local_column] = inward_offset + 1
+ active_count += 1
+ collected_porv += _add_or_collect_porv(
+ dck, submodel_cell, boundary_porv
+ )
+ break
+ if inward_offset == dck.output_ny - 2 - trailing_rows:
+ collected_porv += boundary_porv
+ return _BoundaryMapping(collected_porv, active_count, offsets)
+
+
+
+
+[docs]
+def _map_east_boundary(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ layer_index: int,
+ original_layer: int,
+ column_offset: int,
+ row_offset: int,
+ trailing_rows: int,
+) -> _BoundaryMapping:
+ offsets = np.zeros(dck.output_ny, dtype=int)
+ collected_porv = 0.0
+ active_count = 0
+ height = dck.output_ny - row_offset - trailing_rows
+ east_columns = int(vicinity.layer_min_i[original_layer]) - 1
+ original_row_start = int(vicinity.layer_min_j[original_layer]) - 1
+ pore_volume_grid = np.asarray(dck.original_porv).reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ for local_row in range(height):
+ submodel_row = local_row + row_offset
+ submodel_cell = _submodel_index(dck, column_offset, submodel_row, layer_index)
+ original_row = local_row + original_row_start
+ boundary_porv = float(
+ np.sum(pore_volume_grid[original_layer, original_row, :east_columns])
+ )
+ interior_cell = _original_index(
+ dck, max(east_columns - 1, 0), original_row, original_layer
+ )
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = interior_cell + 1
+ active_count += 1
+ collected_porv += _add_or_collect_porv(dck, submodel_cell, boundary_porv)
+ continue
+ adjacent_cell = interior_cell + 1
+ if (
+ adjacent_cell >= dck.original_porv.size
+ or dck.original_porv[adjacent_cell] <= 0
+ ):
+ collected_porv += boundary_porv
+ continue
+ for inward_offset in range(dck.output_nx - 1 - column_offset):
+ submodel_cell = _submodel_index(
+ dck,
+ inward_offset + column_offset + 1,
+ submodel_row,
+ layer_index,
+ )
+ original_column = inward_offset + east_columns
+ original_cell = _original_index(
+ dck, original_column, original_row, original_layer
+ )
+ boundary_porv += 0.5 * dck.original_porv[original_cell]
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = original_cell + 1
+ offsets[local_row] = inward_offset + 1
+ active_count += 1
+ collected_porv += _add_or_collect_porv(
+ dck, submodel_cell, boundary_porv
+ )
+ break
+ if inward_offset == dck.output_nx - 2 - column_offset:
+ collected_porv += boundary_porv
+ return _BoundaryMapping(collected_porv, active_count, offsets)
+
+
+
+
+[docs]
+def _get_well_completions_for_vicinity(dck: ConfigViaDeck, optvic) -> list:
+ """Collect zero-based completions for a selected well.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration identifying the source DATA file.
+ optvic
+ Well name from the vicinity specification.
+
+ Returns
+ -------
+ list[list[int]]
+ Completion coordinates in ``[i, j, k]`` order."""
+ in_compdat = False
+ deck_path = Path(f"{dck.input_deck_name}.DATA")
+ wvicinity = []
+ with deck_path.open("r", encoding=dck.deck_encoding) as deck_file:
+ for row in csv.reader(deck_file):
+ parsed_line = str(row)[2:-2].strip()
+ if parsed_line == "COMPDAT":
+ in_compdat = True
+ continue
+ if not in_compdat:
+ continue
+ tokens = parsed_line.split()
+ if not tokens:
+ continue
+ if tokens[0] == "/":
+ in_compdat = False
+ continue
+ if tokens[0].startswith("--"):
+ continue
+ well_name = tokens[0].replace("'", "")
+ if well_name != optvic or len(tokens) <= 4:
+ continue
+ source_i = int(tokens[1])
+ source_j = int(tokens[2])
+ source_k1 = int(tokens[3])
+ source_k2 = int(tokens[4])
+ for source_k in range(source_k1, source_k2 + 1):
+ wvicinity.append([source_i - 1, source_j - 1, source_k - 1])
+ return wvicinity
+
+
+
+
+[docs]
+def _map_west_boundary(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ layer_index: int,
+ original_layer: int,
+ row_offset: int,
+ trailing_rows: int,
+ trailing_columns: int,
+) -> _BoundaryMapping:
+ offsets = np.zeros(dck.output_ny, dtype=int)
+ collected_porv = 0.0
+ active_count = 0
+ height = dck.output_ny - row_offset - trailing_rows
+ west_start = int(vicinity.layer_max_i[original_layer])
+ original_row_start = int(vicinity.layer_min_j[original_layer]) - 1
+ pore_volume_grid = np.asarray(dck.original_porv).reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ for local_row in range(height):
+ submodel_row = local_row + row_offset
+ submodel_cell = _submodel_index(
+ dck,
+ dck.output_nx - 1 - trailing_columns,
+ submodel_row,
+ layer_index,
+ )
+ original_row = local_row + original_row_start
+ boundary_porv = float(
+ np.sum(pore_volume_grid[original_layer, original_row, west_start:])
+ )
+ interior_cell = _original_index(
+ dck, west_start - 1, original_row, original_layer
+ )
+ if dck.output_actnum[submodel_cell] > 0:
+ if trailing_columns == 0 and submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = interior_cell
+ active_count += 1
+ collected_porv += _add_or_collect_porv(dck, submodel_cell, boundary_porv)
+ continue
+ if dck.original_porv[interior_cell] <= 0:
+ collected_porv += boundary_porv
+ continue
+ for inward_offset in range(dck.output_nx - 1 - trailing_columns):
+ submodel_column = dck.output_nx - 2 - inward_offset - trailing_columns
+ submodel_cell = _submodel_index(
+ dck, submodel_column, submodel_row, layer_index
+ )
+ original_column = west_start - inward_offset - 1
+ original_cell = _original_index(
+ dck, original_column, original_row, original_layer
+ )
+ boundary_porv += 0.5 * dck.original_porv[original_cell]
+ if dck.output_actnum[submodel_cell] > 0:
+ if submodel_cell not in vicinity.well_indices:
+ vicinity.source_indices[submodel_cell] = original_cell - 1
+ offsets[local_row] = inward_offset + 1
+ active_count += 1
+ collected_porv += _add_or_collect_porv(
+ dck, submodel_cell, boundary_porv
+ )
+ break
+ if inward_offset == dck.output_nx - 2 - trailing_columns:
+ collected_porv += boundary_porv
+ return _BoundaryMapping(collected_porv, active_count, offsets)
+
+
+
+
+[docs]
+def _corner_pore_volumes(
+ dck: ConfigViaDeck, vicinity: VicinityMaps, original_layer: int
+) -> tuple[float, float, float, float]:
+ """Calculate excluded pore volume in the four layer corners.
+
+ Returns
+ -------
+ southwest, southeast, northwest, northeast
+ Corner pore-volume totals for the selected original layer."""
+ pore_volume_grid = np.asarray(dck.original_porv).reshape(
+ dck.original_nz, dck.original_ny, dck.original_nx
+ )
+ minimum_column = int(vicinity.layer_min_i[original_layer]) - 1
+ maximum_column = int(vicinity.layer_max_i[original_layer])
+ minimum_row = int(vicinity.layer_min_j[original_layer]) - 1
+ maximum_row = int(vicinity.layer_max_j[original_layer])
+ layer_porv = pore_volume_grid[original_layer]
+ return (
+ float(np.sum(layer_porv[:minimum_row, :minimum_column])),
+ float(np.sum(layer_porv[:minimum_row, maximum_column:])),
+ float(np.sum(layer_porv[maximum_row:, :minimum_column])),
+ float(np.sum(layer_porv[maximum_row:, maximum_column:])),
+ )
+
+
+
+
+[docs]
+def _apply_layer_pore_volume_correction(
+ dck: ConfigViaDeck,
+ vicinity: VicinityMaps,
+ layer_index: int,
+ column_offset: int,
+ row_offset: int,
+ trailing_columns: int,
+ trailing_rows: int,
+ south: _BoundaryMapping,
+ north: _BoundaryMapping,
+ east: _BoundaryMapping,
+ west: _BoundaryMapping,
+ corner_porv: tuple[float, float, float, float],
+) -> None:
+ layer_start = layer_index * dck.output_nx * dck.output_ny
+ layer_end = layer_start + dck.output_nx * dck.output_ny
+ boundary_indices = (
+ np.flatnonzero(vicinity.source_indices[layer_start:layer_end] != 0)
+ + layer_start
+ )
+ boundary_count = int(boundary_indices.size)
+ if dck.pore_volume_correction == 3:
+ if vicinity.shape != "diamond" and boundary_count > 0:
+ dck.output_porv[boundary_indices] += (
+ vicinity.layer_external_porv[layer_index] / boundary_count
+ )
+ return
+ if dck.pore_volume_correction == 4:
+ if vicinity.shape != "diamond" and vicinity.active_counts[layer_index] > 0:
+ active_indices = (
+ np.flatnonzero(dck.output_actnum[layer_start:layer_end] > 0)
+ + layer_start
+ )
+ dck.output_porv[active_indices] += (
+ vicinity.layer_external_porv[layer_index]
+ / vicinity.active_counts[layer_index]
+ )
+ return
+ southwest_porv, southeast_porv, northwest_porv, northeast_porv = corner_porv
+ width = dck.output_nx - column_offset - trailing_columns
+ for local_column in range(width):
+ south_cell = _submodel_index(
+ dck,
+ local_column + column_offset,
+ south.offsets[local_column] + row_offset,
+ layer_index,
+ )
+ if dck.output_actnum[south_cell] > 0 and south.active_count > 0:
+ increment = south.pore_volume / south.active_count
+ if dck.pore_volume_correction == 1:
+ if south.active_count + east.active_count > 0:
+ increment += southwest_porv / (
+ south.active_count + east.active_count
+ )
+ if south.active_count + west.active_count > 0:
+ increment += southeast_porv / (
+ south.active_count + west.active_count
+ )
+ dck.output_porv[south_cell] += increment
+ north_cell = _submodel_index(
+ dck,
+ local_column + column_offset,
+ dck.output_ny - 1 - north.offsets[local_column] - trailing_rows,
+ layer_index,
+ )
+ if dck.output_actnum[north_cell] > 0 and north.active_count > 0:
+ increment = north.pore_volume / north.active_count
+ if dck.pore_volume_correction == 1:
+ if north.active_count + east.active_count > 0:
+ increment += northwest_porv / (
+ north.active_count + east.active_count
+ )
+ if north.active_count + west.active_count > 0:
+ increment += northeast_porv / (
+ north.active_count + west.active_count
+ )
+ dck.output_porv[north_cell] += increment
+ height = dck.output_ny - row_offset - trailing_rows
+ for local_row in range(height):
+ east_cell = _submodel_index(
+ dck,
+ east.offsets[local_row] + column_offset,
+ local_row + row_offset,
+ layer_index,
+ )
+ if dck.output_actnum[east_cell] > 0 and east.active_count > 0:
+ increment = east.pore_volume / east.active_count
+ if dck.pore_volume_correction == 1:
+ if east.active_count + south.active_count > 0:
+ increment += southwest_porv / (
+ east.active_count + south.active_count
+ )
+ if east.active_count + north.active_count > 0:
+ increment += northwest_porv / (
+ east.active_count + north.active_count
+ )
+ dck.output_porv[east_cell] += increment
+ west_cell = _submodel_index(
+ dck,
+ dck.output_nx - 1 - west.offsets[local_row] - trailing_columns,
+ local_row + row_offset,
+ layer_index,
+ )
+ if dck.output_actnum[west_cell] > 0 and west.active_count > 0:
+ increment = west.pore_volume / west.active_count
+ if dck.pore_volume_correction == 1:
+ if west.active_count + south.active_count > 0:
+ increment += southeast_porv / (
+ west.active_count + south.active_count
+ )
+ if west.active_count + north.active_count > 0:
+ increment += northeast_porv / (
+ west.active_count + north.active_count
+ )
+ dck.output_porv[west_cell] += increment
+ if dck.pore_volume_correction == 2:
+ for corner_column, corner_row, corner_value in (
+ (0, 0, southwest_porv),
+ (dck.output_nx - 1, 0, southeast_porv),
+ (0, dck.output_ny - 1, northwest_porv),
+ (dck.output_nx - 1, dck.output_ny - 1, northeast_porv),
+ ):
+ if corner_value != 0:
+ nearest_index = _find_nearest_active_corner_cell(
+ dck, layer_index, corner_column, corner_row
+ )
+ dck.output_porv[nearest_index] += corner_value
+ expected_porv = (
+ vicinity.layer_external_porv[layer_index]
+ + vicinity.layer_selected_porv[layer_index]
+ )
+ mapped_porv = float(np.sum(dck.output_porv[layer_start:layer_end]))
+ if mapped_porv < expected_porv and boundary_count > 0:
+ dck.output_porv[boundary_indices] += (
+ expected_porv - mapped_porv
+ ) / boundary_count
+
+
+
+
+[docs]
+def _find_nearest_active_corner_cell(
+ dck: ConfigViaDeck, layer_index: int, corner_i: int, corner_j: int
+) -> int:
+ """Return the nearest active cell to a corner in a layer."""
+ layer_start = layer_index * dck.output_nx * dck.output_ny
+ active_cells = np.asarray(dck.output_actnum)[
+ layer_start : layer_start + dck.output_nx * dck.output_ny
+ ].reshape(dck.output_ny, dck.output_nx)
+ active_j, active_i = np.nonzero(active_cells > 0)
+ if active_i.size == 0:
+ raise ValueError(f"No active cells found in submodel layer {layer_index}")
+ distances = np.abs(active_i - corner_i) + np.abs(active_j - corner_j)
+ nearest = np.lexsort((active_i, active_j, distances))[0]
+ return int(layer_start + active_i[nearest] + active_j[nearest] * dck.output_nx)
+
+
+
+
+[docs]
+def _distribute_vertical_pore_volume(
+ dck: ConfigViaDeck, vicinity: VicinityMaps
+) -> None:
+ """Distribute pore volume excluded above and below the submodel.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose ``output_porv`` is updated.
+ vicinity
+ Selection bounds and correction settings."""
+ if vicinity.shape == "diamond" or dck.pore_volume_correction in (3, 4):
+ return
+ cells_per_original_layer = dck.original_nx * dck.original_ny
+ submodel_porv = dck.output_porv.reshape(dck.output_nz, dck.output_ny, dck.output_nx)
+ active_columns = np.any(submodel_porv > 0, axis=0)
+ column_rows, column_columns = np.nonzero(active_columns)
+ if column_rows.size == 0:
+ return
+ if vicinity.min_k > 1:
+ lower_end = (vicinity.min_k - 1) * cells_per_original_layer
+ lower_porv = float(np.sum(dck.original_porv[:lower_end]))
+ first_layers = np.argmax(submodel_porv > 0, axis=0)
+ lower_indices = (
+ first_layers[column_rows, column_columns] * dck.output_nx * dck.output_ny
+ + column_rows * dck.output_nx
+ + column_columns
+ )
+ dck.output_porv[lower_indices] += lower_porv / lower_indices.size
+ if vicinity.max_k < dck.original_nz:
+ upper_start = vicinity.max_k * cells_per_original_layer
+ upper_porv = float(np.sum(dck.original_porv[upper_start:]))
+ last_layers = dck.output_nz - 1 - np.argmax(submodel_porv[::-1] > 0, axis=0)
+ upper_indices = (
+ last_layers[column_rows, column_columns] * dck.output_nx * dck.output_ny
+ + column_rows * dck.output_nx
+ + column_columns
+ )
+ dck.output_porv[upper_indices] += upper_porv / upper_indices.size
+
+
+
+
+[docs]
+def apply_boundary_pore_volume_correction(
+ dck: ConfigViaDeck, vicinity: VicinityMaps
+) -> None:
+ """Map pore volume excluded from the submodel onto active cells.
+
+ The correction strategy is selected by ``dck.pore_volume_correction``. Depending
+ on the chosen method, excluded pore volume is assigned to corresponding
+ boundary cells, nearest corner cells, all boundary cells, or all active cells.
+
+ Parameters
+ ----------
+ dck
+ Deck configuration whose ``output_porv`` is updated.
+ vicinity
+ Selection bounds and pore-volume mapping arrays."""
+ if (
+ vicinity.shape in ("diamond", "diamondxy")
+ and int(dck.vicinity_specification.split()[2]) > 0
+ ):
+ for well_location in vicinity.well_cells:
+ vicinity.well_indices.append(
+ well_location[0]
+ - vicinity.min_i
+ + (well_location[1] - vicinity.min_j + 1) * dck.output_nx
+ + (well_location[2] - vicinity.min_k + 1)
+ * dck.output_nx
+ * dck.output_ny
+ + 1
+ )
+ original_porv = dck.output_porv.copy() if dck.pore_volume_correction == 0 else None
+ for layer_index in range(dck.output_nz):
+ original_layer = layer_index + vicinity.min_k - 1
+ row_offset = int(vicinity.layer_min_j[original_layer]) - vicinity.min_j
+ column_offset = int(vicinity.layer_min_i[original_layer]) - vicinity.min_i
+ trailing_rows = vicinity.max_j - int(vicinity.layer_max_j[original_layer])
+ trailing_columns = vicinity.max_i - int(vicinity.layer_max_i[original_layer])
+ corner_porv = _corner_pore_volumes(dck, vicinity, original_layer)
+ south = _map_south_boundary(
+ dck,
+ vicinity,
+ layer_index,
+ original_layer,
+ column_offset,
+ row_offset,
+ trailing_columns,
+ )
+ north = _map_north_boundary(
+ dck,
+ vicinity,
+ layer_index,
+ original_layer,
+ column_offset,
+ trailing_rows,
+ trailing_columns,
+ )
+ east = _map_east_boundary(
+ dck,
+ vicinity,
+ layer_index,
+ original_layer,
+ column_offset,
+ row_offset,
+ trailing_rows,
+ )
+ west = _map_west_boundary(
+ dck,
+ vicinity,
+ layer_index,
+ original_layer,
+ row_offset,
+ trailing_rows,
+ trailing_columns,
+ )
+ _apply_layer_pore_volume_correction(
+ dck,
+ vicinity,
+ layer_index,
+ column_offset,
+ row_offset,
+ trailing_columns,
+ trailing_rows,
+ south,
+ north,
+ east,
+ west,
+ corner_porv,
+ )
+ _distribute_vertical_pore_volume(dck, vicinity)
+ mapped_porv = float(np.sum(dck.output_porv))
+ expected_porv = float(
+ np.sum(vicinity.layer_external_porv) + np.sum(vicinity.layer_selected_porv)
+ )
+ if dck.pore_volume_correction == 0 and original_porv is not None:
+ dck.output_porv = original_porv
+ elif dck.pore_volume_correction in (1, 2, 3):
+ correction_indices = np.flatnonzero(vicinity.source_indices != 0)
+ if correction_indices.size > 0:
+ dck.output_porv[correction_indices] += (
+ expected_porv - mapped_porv
+ ) / correction_indices.size
+ else:
+ correction_frequency = int(np.sum(vicinity.active_counts))
+ active_indices = np.flatnonzero(dck.output_actnum > 0)
+ if correction_frequency > 0:
+ dck.output_porv[active_indices] += (
+ expected_porv - mapped_porv
+ ) / correction_frequency
+
+Short
+ */ + .o-tooltip--left { + position: relative; + } + + .o-tooltip--left:after { + opacity: 0; + visibility: hidden; + position: absolute; + content: attr(data-tooltip); + padding: .2em; + font-size: .8em; + left: -.2em; + background: grey; + color: white; + white-space: nowrap; + z-index: 2; + border-radius: 2px; + transform: translateX(-102%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); +} + +.o-tooltip--left:hover:after { + display: block; + opacity: 1; + visibility: visible; + transform: translateX(-100%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); + transition-delay: .5s; +} + +/* By default the copy button shouldn't show up when printing a page */ +@media print { + button.copybtn { + display: none; + } +} diff --git a/docs/_static/copybutton.js b/docs/_static/copybutton.js new file mode 100644 index 0000000..bfda98a --- /dev/null +++ b/docs/_static/copybutton.js @@ -0,0 +1,248 @@ +// Localization support +const messages = { + 'en': { + 'copy': 'Copy', + 'copy_to_clipboard': 'Copy to clipboard', + 'copy_success': 'Copied!', + 'copy_failure': 'Failed to copy', + }, + 'es' : { + 'copy': 'Copiar', + 'copy_to_clipboard': 'Copiar al portapapeles', + 'copy_success': '¡Copiado!', + 'copy_failure': 'Error al copiar', + }, + 'de' : { + 'copy': 'Kopieren', + 'copy_to_clipboard': 'In die Zwischenablage kopieren', + 'copy_success': 'Kopiert!', + 'copy_failure': 'Fehler beim Kopieren', + }, + 'fr' : { + 'copy': 'Copier', + 'copy_to_clipboard': 'Copier dans le presse-papier', + 'copy_success': 'Copié !', + 'copy_failure': 'Échec de la copie', + }, + 'ru': { + 'copy': 'Скопировать', + 'copy_to_clipboard': 'Скопировать в буфер', + 'copy_success': 'Скопировано!', + 'copy_failure': 'Не удалось скопировать', + }, + 'zh-CN': { + 'copy': '复制', + 'copy_to_clipboard': '复制到剪贴板', + 'copy_success': '复制成功!', + 'copy_failure': '复制失败', + }, + 'it' : { + 'copy': 'Copiare', + 'copy_to_clipboard': 'Copiato negli appunti', + 'copy_success': 'Copiato!', + 'copy_failure': 'Errore durante la copia', + } +} + +let locale = 'en' +if( document.documentElement.lang !== undefined + && messages[document.documentElement.lang] !== undefined ) { + locale = document.documentElement.lang +} + +let doc_url_root = DOCUMENTATION_OPTIONS.URL_ROOT; +if (doc_url_root == '#') { + doc_url_root = ''; +} + +/** + * SVG files for our copy buttons + */ +let iconCheck = `` + +// If the user specified their own SVG use that, otherwise use the default +let iconCopy = ``; +if (!iconCopy) { + iconCopy = `` +} + +/** + * Set up copy/paste for code blocks + */ + +const runWhenDOMLoaded = cb => { + if (document.readyState != 'loading') { + cb() + } else if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', cb) + } else { + document.attachEvent('onreadystatechange', function() { + if (document.readyState == 'complete') cb() + }) + } +} + +const codeCellId = index => `codecell${index}` + +// Clears selected text since ClipboardJS will select the text when copying +const clearSelection = () => { + if (window.getSelection) { + window.getSelection().removeAllRanges() + } else if (document.selection) { + document.selection.empty() + } +} + +// Changes tooltip text for a moment, then changes it back +// We want the timeout of our `success` class to be a bit shorter than the +// tooltip and icon change, so that we can hide the icon before changing back. +var timeoutIcon = 2000; +var timeoutSuccessClass = 1500; + +const temporarilyChangeTooltip = (el, oldText, newText) => { + el.setAttribute('data-tooltip', newText) + el.classList.add('success') + // Remove success a little bit sooner than we change the tooltip + // So that we can use CSS to hide the copybutton first + setTimeout(() => el.classList.remove('success'), timeoutSuccessClass) + setTimeout(() => el.setAttribute('data-tooltip', oldText), timeoutIcon) +} + +// Changes the copy button icon for two seconds, then changes it back +const temporarilyChangeIcon = (el) => { + el.innerHTML = iconCheck; + setTimeout(() => {el.innerHTML = iconCopy}, timeoutIcon) +} + +const addCopyButtonToCodeCells = () => { + // If ClipboardJS hasn't loaded, wait a bit and try again. This + // happens because we load ClipboardJS asynchronously. + if (window.ClipboardJS === undefined) { + setTimeout(addCopyButtonToCodeCells, 250) + return + } + + // Add copybuttons to all of our code cells + const COPYBUTTON_SELECTOR = 'div.highlight pre'; + const codeCells = document.querySelectorAll(COPYBUTTON_SELECTOR) + codeCells.forEach((codeCell, index) => { + const id = codeCellId(index) + codeCell.setAttribute('id', id) + + const clipboardButton = id => + `` + codeCell.insertAdjacentHTML('afterend', clipboardButton(id)) + }) + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Removes excluded text from a Node. + * + * @param {Node} target Node to filter. + * @param {string} exclude CSS selector of nodes to exclude. + * @returns {DOMString} Text from `target` with text removed. + */ +function filterText(target, exclude) { + const clone = target.cloneNode(true); // clone as to not modify the live DOM + if (exclude) { + // remove excluded nodes + clone.querySelectorAll(exclude).forEach(node => node.remove()); + } + return clone.innerText; +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} + + +var copyTargetText = (trigger) => { + var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); + + // get filtered text + let exclude = '.linenos'; + + let text = filterText(target, exclude); + return formatCopyText(text, '>>> |\\.\\.\\. |\\$ |# ', true, true, true, true, '', '') +} + + // Initialize with a callback so we can modify the text before copy + const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText}) + + // Update UI with error/success messages + clipboard.on('success', event => { + clearSelection() + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_success']) + temporarilyChangeIcon(event.trigger) + }) + + clipboard.on('error', event => { + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_failure']) + }) +} + +runWhenDOMLoaded(addCopyButtonToCodeCells) \ No newline at end of file diff --git a/docs/_static/copybutton_funcs.js b/docs/_static/copybutton_funcs.js new file mode 100644 index 0000000..dbe1aaa --- /dev/null +++ b/docs/_static/copybutton_funcs.js @@ -0,0 +1,73 @@ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Removes excluded text from a Node. + * + * @param {Node} target Node to filter. + * @param {string} exclude CSS selector of nodes to exclude. + * @returns {DOMString} Text from `target` with text removed. + */ +export function filterText(target, exclude) { + const clone = target.cloneNode(true); // clone as to not modify the live DOM + if (exclude) { + // remove excluded nodes + clone.querySelectorAll(exclude).forEach(node => node.remove()); + } + return clone.innerText; +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +export function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} diff --git a/docs/_static/css/badge_only.css b/docs/_static/css/badge_only.css deleted file mode 100644 index 88ba55b..0000000 --- a/docs/_static/css/badge_only.css +++ /dev/null @@ -1 +0,0 @@ -.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px} \ No newline at end of file diff --git a/docs/_static/css/fonts/Roboto-Slab-Bold.woff b/docs/_static/css/fonts/Roboto-Slab-Bold.woff deleted file mode 100644 index 6cb6000..0000000 Binary files a/docs/_static/css/fonts/Roboto-Slab-Bold.woff and /dev/null differ diff --git a/docs/_static/css/fonts/Roboto-Slab-Bold.woff2 b/docs/_static/css/fonts/Roboto-Slab-Bold.woff2 deleted file mode 100644 index 7059e23..0000000 Binary files a/docs/_static/css/fonts/Roboto-Slab-Bold.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/Roboto-Slab-Regular.woff b/docs/_static/css/fonts/Roboto-Slab-Regular.woff deleted file mode 100644 index f815f63..0000000 Binary files a/docs/_static/css/fonts/Roboto-Slab-Regular.woff and /dev/null differ diff --git a/docs/_static/css/fonts/Roboto-Slab-Regular.woff2 b/docs/_static/css/fonts/Roboto-Slab-Regular.woff2 deleted file mode 100644 index f2c76e5..0000000 Binary files a/docs/_static/css/fonts/Roboto-Slab-Regular.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/fontawesome-webfont.eot b/docs/_static/css/fonts/fontawesome-webfont.eot deleted file mode 100644 index e9f60ca..0000000 Binary files a/docs/_static/css/fonts/fontawesome-webfont.eot and /dev/null differ diff --git a/docs/_static/css/fonts/fontawesome-webfont.svg b/docs/_static/css/fonts/fontawesome-webfont.svg deleted file mode 100644 index 855c845..0000000 --- a/docs/_static/css/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,2671 +0,0 @@ - - - diff --git a/docs/_static/css/fonts/fontawesome-webfont.ttf b/docs/_static/css/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 35acda2..0000000 Binary files a/docs/_static/css/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/docs/_static/css/fonts/fontawesome-webfont.woff b/docs/_static/css/fonts/fontawesome-webfont.woff deleted file mode 100644 index 400014a..0000000 Binary files a/docs/_static/css/fonts/fontawesome-webfont.woff and /dev/null differ diff --git a/docs/_static/css/fonts/fontawesome-webfont.woff2 b/docs/_static/css/fonts/fontawesome-webfont.woff2 deleted file mode 100644 index 4d13fc6..0000000 Binary files a/docs/_static/css/fonts/fontawesome-webfont.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/lato-bold-italic.woff b/docs/_static/css/fonts/lato-bold-italic.woff deleted file mode 100644 index 88ad05b..0000000 Binary files a/docs/_static/css/fonts/lato-bold-italic.woff and /dev/null differ diff --git a/docs/_static/css/fonts/lato-bold-italic.woff2 b/docs/_static/css/fonts/lato-bold-italic.woff2 deleted file mode 100644 index c4e3d80..0000000 Binary files a/docs/_static/css/fonts/lato-bold-italic.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/lato-bold.woff b/docs/_static/css/fonts/lato-bold.woff deleted file mode 100644 index c6dff51..0000000 Binary files a/docs/_static/css/fonts/lato-bold.woff and /dev/null differ diff --git a/docs/_static/css/fonts/lato-bold.woff2 b/docs/_static/css/fonts/lato-bold.woff2 deleted file mode 100644 index bb19504..0000000 Binary files a/docs/_static/css/fonts/lato-bold.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/lato-normal-italic.woff b/docs/_static/css/fonts/lato-normal-italic.woff deleted file mode 100644 index 76114bc..0000000 Binary files a/docs/_static/css/fonts/lato-normal-italic.woff and /dev/null differ diff --git a/docs/_static/css/fonts/lato-normal-italic.woff2 b/docs/_static/css/fonts/lato-normal-italic.woff2 deleted file mode 100644 index 3404f37..0000000 Binary files a/docs/_static/css/fonts/lato-normal-italic.woff2 and /dev/null differ diff --git a/docs/_static/css/fonts/lato-normal.woff b/docs/_static/css/fonts/lato-normal.woff deleted file mode 100644 index ae1307f..0000000 Binary files a/docs/_static/css/fonts/lato-normal.woff and /dev/null differ diff --git a/docs/_static/css/fonts/lato-normal.woff2 b/docs/_static/css/fonts/lato-normal.woff2 deleted file mode 100644 index 3bf9843..0000000 Binary files a/docs/_static/css/fonts/lato-normal.woff2 and /dev/null differ diff --git a/docs/_static/css/theme.css b/docs/_static/css/theme.css deleted file mode 100644 index a88467c..0000000 --- a/docs/_static/css/theme.css +++ /dev/null @@ -1,4 +0,0 @@ -html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! - * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search .wy-dropdown>aactive,.wy-side-nav-search .wy-dropdown>afocus,.wy-side-nav-search>a:hover,.wy-side-nav-search>aactive,.wy-side-nav-search>afocus{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon,.wy-side-nav-search>a.icon{display:block}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.switch-menus{position:relative;display:block;margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-side-nav-search>div.switch-menus>div.language-switch,.wy-side-nav-search>div.switch-menus>div.version-switch{display:inline-block;padding:.2em}.wy-side-nav-search>div.switch-menus>div.language-switch select,.wy-side-nav-search>div.switch-menus>div.version-switch select{display:inline-block;margin-right:-2rem;padding-right:2rem;max-width:240px;text-align-last:center;background:none;border:none;border-radius:0;box-shadow:none;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-size:1em;font-weight:400;color:hsla(0,0%,100%,.3);cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none}.wy-side-nav-search>div.switch-menus>div.language-switch select:active,.wy-side-nav-search>div.switch-menus>div.language-switch select:focus,.wy-side-nav-search>div.switch-menus>div.language-switch select:hover,.wy-side-nav-search>div.switch-menus>div.version-switch select:active,.wy-side-nav-search>div.switch-menus>div.version-switch select:focus,.wy-side-nav-search>div.switch-menus>div.version-switch select:hover{background:hsla(0,0%,100%,.1);color:hsla(0,0%,100%,.5)}.wy-side-nav-search>div.switch-menus>div.language-switch select option,.wy-side-nav-search>div.switch-menus>div.version-switch select option{color:#000}.wy-side-nav-search>div.switch-menus>div.language-switch:has(>select):after,.wy-side-nav-search>div.switch-menus>div.version-switch:has(>select):after{display:inline-block;width:1.5em;height:100%;padding:.1em;content:"\f0d7";font-size:1em;line-height:1.2em;font-family:FontAwesome;text-align:center;pointer-events:none;box-sizing:border-box}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions .rst-other-versions .rtd-current-item{font-weight:700}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}#flyout-search-form{padding:6px}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%;float:none;margin-left:0}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..b704b1d --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,101 @@ +:root { + --pst-color-primary: #1769aa; + --pst-color-secondary: #3f8fc5; + --pst-color-link: #1769aa; + --pst-color-link-hover: #0f4f82; +} + +html[data-theme="dark"] { + --pst-color-primary: #71b7e6; + --pst-color-secondary: #8cc8ec; + --pst-color-link: #71b7e6; + --pst-color-link-hover: #a7d8f4; +} + +.bd-main .bd-content .bd-article-container { + max-width: 72rem; +} + +.bd-page-width { + max-width: 96rem; +} + +.bd-header .navbar-header-items__start { + min-width: max-content; +} + +.navbar-brand .title { + font-weight: 700; + letter-spacing: 0.01em; +} + +.bd-header .navbar-brand img { + max-height: 102px; +} + +.sd-card { + height: 100%; + overflow: hidden; + border-radius: 0.75rem; + transition: + transform 160ms ease, + box-shadow 160ms ease, + border-color 160ms ease; +} + +.sd-card-title { + font-weight: 650; +} + +.sd-card:hover { + transform: translateY(-3px); + border-color: var(--pst-color-primary); + box-shadow: 0 0.5rem 1.25rem rgb(0 0 0 / 14%); +} + +.sd-card .sd-card-title { + color: var(--pst-color-primary); +} + +.sd-card .sd-card-body { + padding: 1rem; +} + +.rst-class.lead, +.lead { + font-size: 1.2rem; + color: var(--pst-color-text-muted); +} + +div.highlight pre { + font-size: 0.9rem; +} + +table.docutils { + width: 100%; +} + +.sd-card-img-top { + display: block; + width: 100%; + height: 250px; + object-fit: contain; + object-position: center; + padding: 0.75rem; + background: var(--pst-color-surface); + border-bottom: 1px solid var(--pst-color-border); +} + +html[data-theme="dark"] .sd-card-img-top { + opacity: 0.92; +} + +.related-projects .sd-card-img-top { + height: 260px; + padding: 1rem; +} + +.related-projects .sd-card-footer { + color: var(--pst-color-primary); + font-weight: 600; +} diff --git a/docs/_static/design-tabs.js b/docs/_static/design-tabs.js new file mode 100644 index 0000000..b25bd6a --- /dev/null +++ b/docs/_static/design-tabs.js @@ -0,0 +1,101 @@ +// @ts-check + +// Extra JS capability for selected tabs to be synced +// The selection is stored in local storage so that it persists across page loads. + +/** + * @type {Record