Repository: snek5000/snek5000 (language: python)

Issue:
Loading a simulation without write permission
It should be possible. Nothing should be written when we do only `sim = load()`.

```
augier@...:~/useful/project/20CONVECTION/numerical/cbox/2D/NL_sim/asp_1.00/msh_160_160/Ra_1.807e+08/cbox_run_16x16x8_V1.x1.x1._2021-10-29_12-34-26$ ipython --matplotlib -i -c "from snek5000 import load; sim = load()"
Python 3.9.7 (default, Sep 17 2021, 09:40:46) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.27.0 -- An enhanced Interactive Python. Type '?' for help.
Using matplotlib backend: Qt5Agg
/home/users/augier/Dev/snek5000/src/snek5000/params.py:65: UserWarning: Loading directly from path_file is an experimental feature. Use Simul.load_params_from_file() instead.
  warn(
[10/29/21 16:07:08] WARNING  Missing a configuration file describing compilers and flags. Create one at either of the following paths to avoid future warnings:                       base.py:145
                             /home/users/augier/.config/snek5000/meige8pcpa79.yml                                                                                                             
                             /home/users/augier/Dev/snek5000-cbox/src/snek5000_cbox/etc/meige8pcpa79.yml                                                                                      
                    INFO     Using default configuration for now:                                                                                                                     base.py:154
                             /home/users/augier/Dev/snek5000/src/snek5000/assets/default_configfile.yml                                                                                       
                    WARNING  No module named 'paraview'                                                                                                                         phys_fields.py:12
                    INFO     ******************************************                                                                                                               base.py:492
                    INFO     solver: <class 'snek5000_cbox.output.OutputCbox'>                                                                                                        base.py:493
                    INFO     path_run:                                                                                                                                                base.py:494
                             /.fsdyn_people/augier/project/20CONVECTION/numerical/cbox/2D/NL_sim/asp_1.00/msh_160_160/Ra_1.807e+08/cbox_run_16x16x8_V1.x1.x1._2021-10-29_12-34-26             
                    INFO     ******************************************                                                                                                               base.py:495
sim:                <class 'snek5000_cbox.solver.SimulCbox'>
                    INFO     Searching for a log file...                                                                                                                       print_stdout.py:29
---------------------------------------------------------------------------
PermissionError                           Traceback (most recent call last)
<ipython-input-1-9bf224db26da> in <module>
----> 1 from snek5000 import load; sim = load()

~/Dev/snek5000/src/snek5000/__init__.py in load_simul(path_dir)
    133     params.output.HAS_TO_SAVE = False
    134     params.path_run = path_dir
--> 135     sim = Simul(params)
    136     return sim
    137 

~/Dev/snek5000/src/snek5000/solvers/base.py in __init__(self, params)
    192                 self.path_run = Path(self.output.path_run)
    193 
--> 194             self.output.post_init()
    195         else:
    196             self.path_run = None

~/Dev/snek5000-cbox/src/snek5000_cbox/output.py in post_init(self)
     38 
     39     def post_init(self):
---> 40         super().post_init()
     41 
     42         # Write additional source files to compile the simulation

~/Dev/snek5000/src/snek5000/output/base.py in post_init(self)
    496 
    497         # This also calls _save_info_solver_params_xml
--> 498         super().post_init()
    499 
    500         # Write source files to compile the simulation

~/Dev/fluidsim/lib/fluidsim_core/output.py in post_init(self)
    338 
    339             for key, obj in objects_to_print.items():
--> 340                 self.print_stdout(
    341                     "{:20s}".format(key + ": ") + str(obj.__class__)
    342                 )

~/Dev/snek5000/src/snek5000/output/print_stdout.py in __call__(self, *args)
    110         mpi.printby0(*args)
    111         if mpi.rank == 0:
--> 112             with self.file.open("a") as f:
    113                 f.write(" ".join(str(a) for a in args) + "\n")

~/.pyenv/versions/3.9.7/lib/python3.9/pathlib.py in open(self, mode, buffering, encoding, errors, newline)
   1250         the built-in open() function does.
   1251         """
-> 1252         return io.open(self, mode, buffering, encoding, errors, newline,
   1253                        opener=self._opener)
   1254 

~/.pyenv/versions/3.9.7/lib/python3.9/pathlib.py in _opener(self, name, flags, mode)
   1118     def _opener(self, name, flags, mode=0o666):
   1119         # A stub for the opener argument to built-in open()
-> 1120         return self._accessor.open(self, flags, mode)
   1121 
   1122     def _raw_open(self, flags, mode=0o777):

PermissionError: [Errno 13] Permission denied: '/.fsdyn_people/augier/project/20CONVECTION/numerical/cbox/2D/NL_sim/asp_1.00/msh_160_160/Ra_1.807e+08/cbox_run_16x16x8_V1.x1.x1._2021-10-29_12-34-26/cbox.log'
```


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f8996c5..be53a714 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,5 @@
 # Changelog
+
 All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
@@ -35,6 +36,8 @@ Security in case of vulnerabilities.
 - {mod}`snek5000.util.files`
 - {meth}`snek5000.output.base.Output.get_field_file` to locate a field file
 - Mandatory key `MPIEXEC_FLAGS` in Snakemake config
+- {class}`snek5000.output.history_points.HistoryPoints` for `Nek5000 history points
+  <https://nek5000.github.io/NekDoc/problem_setup/features.html#history-points>`__
 
 ### Changed
 
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..4ae6bc50
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+dev:
+	pip install -e .[dev]
+
+test:
+	pytest
+
+testslow:
+	pytest --runslow
+
+coverage_html:
+	coverage html
+	@echo "Code coverage analysis complete. View detailed report:"
+	@echo "file://${PWD}/.coverage/html/index.html"
+
+black:
+	black src tests
diff --git a/setup.cfg b/setup.cfg
index ea8b5ae1..753d299c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -14,6 +14,8 @@ classifiers =
         License :: OSI Approved :: BSD License
         Natural Language :: English
         Programming Language :: Python :: 3 :: Only
+        Programming Language :: Python :: 3.8
+        Programming Language :: Python :: 3.9
         Topic :: Scientific/Engineering
 project-urls =
     Bug Tracker = https://github.com/exabl/snek5000/issues
@@ -87,3 +89,26 @@ max-line-length = 82
 ## Not possible because we have a git dependency in tests
 # [bdist_wheel]
 # universal = 1
+
+[coverage:run]
+source =
+  ./src
+data_file = .coverage/coverage
+omit =
+    */try_*.py
+    */_old_*.py
+
+[coverage:report]
+show_missing = True
+exclude_lines =
+    if __name__ == .__main__.:
+    if "sphinx" in sys.modules:
+    raise ValueError
+    raise NotImplementedError
+    except KeyError:
+    except ImportError:
+    except AttributeError:
+    except NotImplementedError:
+
+[coverage:html]
+directory = .coverage/html
diff --git a/src/snek5000/assets/compiler.smk b/src/snek5000/assets/compiler.smk
index 6e1584c9..0e0ded7b 100644
--- a/src/snek5000/assets/compiler.smk
+++ b/src/snek5000/assets/compiler.smk
@@ -71,6 +71,7 @@ rule mpiexec:
         echo "Log file:"
         realpath {config[CASE]}.log
         {config[MPIEXEC]} -n {resources.nproc} {config[MPIEXEC_FLAGS]} ./nek5000 {params.redirect} {log} {params.end}
+        echo $PWD
         """
 
 
diff --git a/src/snek5000/operators.py b/src/snek5000/operators.py
index 01098c85..87f09151 100644
--- a/src/snek5000/operators.py
+++ b/src/snek5000/operators.py
@@ -136,7 +136,8 @@ def _complete_params_with_default(params):
 - ``boundary_scalars``: list[str]
     :ref:`Temperature and passive scalar boundary conditions <nek:tab:userbct>`
 
-The following table matches counterpart of mandatory ``SIZE`` variables.
+The following table matches counterpart of mandatory :ref:`SIZE
+<nek:case_files_size>` variables.
 
 ==========  ===================   =============================================
 SIZE        params.oper           Comment
@@ -169,9 +170,10 @@ def _complete_params_with_default(params):
         params.oper._set_child("max", attribs=attribs)
         params.oper.max._set_doc(
             """
-The following table matches counterpart of optional ``SIZE`` variables. These
-refer to upper bound number of `something`. The parameters are considered
-"optional" and would be ignored with the default values.
+The following table matches counterpart of optional :ref:`SIZE
+<nek:case_files_size>` variables. These refer to upper bound number of
+`something`. The parameters are considered "optional" and would be ignored with
+the default values.
 
 ==============  ===================   =========================================
 SIZE            params.oper.max       Comment
diff --git a/src/snek5000/output/base.py b/src/snek5000/output/base.py
index ba380c5a..fdaa23d6 100644
--- a/src/snek5000/output/base.py
+++ b/src/snek5000/output/base.py
@@ -13,6 +13,8 @@
 from socket import gethostname
 
 from fluidsim_core.output import OutputCore
+from fluidsim_core.params import iter_complete_params
+
 from inflection import underscore
 
 from snek5000 import __version__, get_asset, logger, mpi, resources
@@ -39,7 +41,7 @@ class Output(OutputCore):
 
         Initialized from ``sim.info.solver.short_name`` used to discover source
         code files, such as usr, box, par files. The value of ``name_solver``
-        i s also used to identify the entrypoint pointing to the solver module.
+        is also used to identify the entrypoint pointing to the solver module.
         Have a look at the :ref:`packaging tutorial <packaging>`.
 
     .. autoattribute:: package
@@ -115,6 +117,13 @@ def _complete_info_solver(info_solver):
             dict(module_name="snek5000.output.phys_fields", class_name="PhysFields"),
         )
 
+        classes._set_child(
+            "HistoryPoints",
+            dict(
+                module_name="snek5000.output.history_points", class_name="HistoryPoints"
+            ),
+        )
+
     @staticmethod
     def _complete_params_with_default(params, info_solver):
         """This static method is used to complete the *params* container."""
@@ -146,6 +155,9 @@ def _complete_params_with_default(params, info_solver):
             )
         )
 
+        dict_classes = info_solver.classes.Output.import_classes()
+        iter_complete_params(params, info_solver, dict_classes.values())
+
     @classmethod
     def get_root(cls):
         """Get the path to the current package."""
@@ -284,17 +296,8 @@ def __init__(self, sim=None, params=None):
         if sim:
             self.oper = sim.oper
             self.params = sim.params.output
-
             # Same as package name __name__
             super().__init__(sim)
-
-            dict_classes = sim.info.solver.classes.Output.import_classes()
-
-            # initialize objects
-            for cls_name, Class in dict_classes.items():
-                # only initialize if Class is not the Output class
-                if not isinstance(self, Class):
-                    setattr(self, underscore(cls_name), Class(self))
         elif params:
             # At least initialize params
             self.params = params.output
@@ -306,6 +309,14 @@ def __init__(self, sim=None, params=None):
 
         self.path_session = self._init_path_session()
 
+        if sim:
+            # initialize objects
+            dict_classes = sim.info.solver.classes.Output.import_classes()
+            for cls_name, Class in dict_classes.items():
+                # only initialize if Class is not the Output class
+                if not isinstance(self, Class):
+                    setattr(self, underscore(cls_name), Class(self))
+
     def _init_path_session(self):
         """Initialize :attr:`path_session` and ``params.output.path_session``
         from ``params.output.session_id``. Unlike :meth:`_init_path_run`, the
diff --git a/src/snek5000/output/history_points.py b/src/snek5000/output/history_points.py
new file mode 100644
index 00000000..027aadb3
--- /dev/null
+++ b/src/snek5000/output/history_points.py
@@ -0,0 +1,119 @@
+from io import StringIO
+
+import numpy as np
+import matplotlib.pyplot as plt
+import pandas as pd
+
+
+class HistoryPoints:
+    """History points (Nek5000 output)
+
+    See https://nek5000.github.io/NekDoc/problem_setup/features.html#history-points
+
+    """
+
+    def __init__(self, output=None):
+        self.output = output
+        sim = output.sim
+        params = sim.params
+        hp_params = params.output.history_points
+
+        self.coords = hp_params.coords
+
+        if self.coords is None:
+            return
+
+        self.nb_points = len(self.coords)
+        self.coords = np.array(self.coords)
+        shape = self.coords.shape
+
+        if shape[0] >= params.oper.max.hist or shape[1] != params.oper.dim:
+            raise ValueError(
+                "Shape of params.output.history_points.coords are not compatible."
+                f"Should be (< {params.oper.max.hist = }, == {params.oper.dim = })"
+            )
+
+        self.path_file = output.path_session / f"{sim.info_solver.short_name}.his"
+        if self.path_file.exists() or not self.output._has_to_save:
+            return
+
+        # temporary?
+        output.path_session.mkdir(exist_ok=True)
+
+        with open(self.path_file, "w") as file:
+            file.write(f"{self.nb_points} !number of monitoring coords\n")
+            np.savetxt(file, self.coords, fmt="%.7E")
+
+    @classmethod
+    def _complete_params_with_default(cls, params):
+        params.output._set_child(
+            "history_points",
+            attribs={"coords": None},
+        )
+
+    def load(self):
+        with open(self.path_file) as file:
+            line = file.readline()
+            nb_points = int(line.split(" ", 1)[0])
+            coords = np.loadtxt(file, max_rows=nb_points)
+            lines = file.readlines()
+
+        columns = ["time", "vx", "vy"]
+
+        sim = self.output.sim
+        if sim.params.oper.dim == 3:
+            columns.append("vz")
+
+        columns.append("pressure")
+
+        for key in ("temperature", "scalar01"):
+            if key not in sim.info_solver.par_sections_disabled:
+                columns.append(key)
+
+        # we want to be able to load data during the simulation
+        if lines:
+            nb_numbers_per_line = len(lines[0])
+
+            while len(lines[-1]) != nb_numbers_per_line:
+                lines = lines[:-1]
+
+            nb_times = len(lines) // nb_points
+            lines = lines[: nb_times * nb_points]
+            df = pd.read_fwf(StringIO("\n".join(lines)), header=None)
+            df.columns = columns
+        else:
+            nb_times = 0
+            df = pd.DataFrame({}, columns=columns)
+
+        index_points = list(range(nb_points)) * nb_times
+        df["index_points"] = index_points
+
+        if sim.params.oper.dim == 2:
+            columns = list("xy")
+        else:
+            columns = list("xyz")
+
+        coords = pd.DataFrame(coords, columns=columns)
+
+        return coords, df
+
+    def plot(self, key="vx", data=None):
+        if data is None:
+            coords, df = self.load()
+        else:
+            coords, df = data
+
+        fig, ax = plt.subplots()
+
+        for index in range(self.nb_points):
+            df_point = df[df.index_points == index]
+            signal = df_point[key]
+            times = df_point["time"]
+            ax.plot(times, signal, label=str(tuple(coords.iloc[index])))
+
+        ax.set_xlabel("time")
+        ax.set_ylabel(key)
+        fig.legend()
+        fig.tight_layout()
+
+        return ax
diff --git a/src/snek5000/output/print_stdout.py b/src/snek5000/output/print_stdout.py
index 235d576c..c332ebbe 100644
--- a/src/snek5000/output/print_stdout.py
+++ b/src/snek5000/output/print_stdout.py
@@ -108,6 +108,6 @@ def dt(self):
     def __call__(self, *args):
         """Print to stdout and log file simultaneously."""
         mpi.printby0(*args)
-        if mpi.rank == 0:
+        if mpi.rank == 0 and self.output._has_to_save:
             with self.file.open("a") as f:
                 f.write(" ".join(str(a) for a in args) + "\n")


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8d4c91a9..6898ad01 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -16,7 +16,7 @@ jobs:
     strategy:
       max-parallel: 5
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: [3.8, 3.9]
 
     steps:
     - name: Checkout
@@ -120,4 +120,3 @@ jobs:
         password: ${{ secrets.TEST_PYPI_API_TOKEN }}
         repository_url: https://test.pypi.org/legacy/
         # password: ${{ secrets.PYPI_API_TOKEN }}
-
diff --git a/tests/test_solver.py b/tests/test_solver.py
index 89e2a215..40225877 100644
--- a/tests/test_solver.py
+++ b/tests/test_solver.py
@@ -40,3 +40,36 @@ def test_output(sim_data):
         sim.output.print_stdout.file.exists()
     ), "Cannot find .log file for print_stdout"
     assert sim.output.phys_fields.path_run.exists(), "Cannot initialize phys_fields"
+
+
+def test_init_output():
+    from phill.solver import Simul
+
+    params = Simul.create_default_params()
+
+    # init history_points
+    coords = params.output.history_points.coords = [
+        (0.5, 0.2, 0.5),
+        (0.5, 0.8, 0.5),
+    ]
+    params.oper.max.hist = len(coords) + 1
+
+    sim = Simul(params)
+
+    # tests history_points
+    assert sim.output.history_points.path_file.exists()
+    coords, df = sim.output.history_points.load()
+
+    assert len(df) == 0
+    assert tuple(coords.iloc[-1]) == params.output.history_points.coords[-1]
+
+    with open(sim.output.history_points.path_file, "a") as file:
+        file.write(
+            "\n".join(
+                ["0.0 2.0 2.0 2.0 2.0"] * 2
+                + ["0.5 2.0 2.0 2.0 2.0"] * 2
+                + ["1.0 2.0 2.0 2.0 2.0\n2.0 2.0\n"]
+            )
+        )
+
+    sim.output.history_points.plot()