diff --git a/.conda/recipe/meta.yaml b/.conda/recipe/meta.yaml index 7e2040e..71f5ef7 100644 --- a/.conda/recipe/meta.yaml +++ b/.conda/recipe/meta.yaml @@ -1,4 +1,4 @@ -{% set version = "0.5.3" %} +{% set version = "0.6.0" %} package: name: snl-delft3d-cec-verify diff --git a/README.md b/README.md index 763922e..06511b4 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ From a conda prompt create a named environment in which to install the for future updates: ``` -(base) > conda create -y -n snld3d --override-channels -c conda-forge -c dataonlygreater snl-delft3d-cec-verify=0.5.3 +(base) > conda create -y -n snld3d --override-channels -c conda-forge -c dataonlygreater snl-delft3d-cec-verify=0.6.0 (base) > conda activate snld3d (snld3d) > conda config --env --add channels conda-forge --add channels dataonlygreater (snld3d) > conda config --env --set channel_priority strict @@ -212,10 +212,10 @@ If successful, the report files (and images) will be placed into a sub-directory based on the model type. For the flexible mesh model, this is `structured/grid_convergence_report`. To avoid repeating simulations in the event of an unexpected failure or change to the `grid_convergence.py` file, -the Delft3D simulations are stored in a sub-directory based on the model type. -For the flexible mesh model, this is `structured/grid_convergence_runs`. If -Delft3D is updated, ensure to delete or move this folder, so that new -simulations are conducted. +the Delft3D simulations, and a copy of their case study parameters, are stored +in a sub-directory based on the model type. For the structured grid model, for +example, this is `structured/runs`. If the Delft3D solver is updated, ensure to +delete or move this folder, so that new simulations are conducted. By default, the study is conducted using just one CPU thread. To reduce simulation time of the `fm` model, assuming additional capacity is available, @@ -237,6 +237,73 @@ optional argument to reduce the number of experiments. For example: Pre-calculated results of the full study are available in the [online documentation][110]. +### Model Comparison Study + +Required files: ++ `comparison.py` ++ `examples.bib` (for conversion to Word format) ++ `reference.docx` (for conversion to Word format) + +The second production example is a comparison of the flexible mesh and +structured grid solvers for a turbine simulation using identical settings. + +This example uses the [pandoc-crossref][119] package to reference sections +and figures within the generated report. To install the package (for converting +to Word format with pypandoc) issue the following command: + +``` +(snld3d) > conda install pandoc-crossref=0.3.10.0 +``` + +For the example to run, two environment variable **must be set**. For path to +the flexible mesh solver, set the `D3D_FM_BIN` variable. In PowerShell, for +example: + +``` +(snld3d) > $env:D3D_FM_BIN = "\path\to\SNL-Delft3D-FM-CEC\src\bin" +``` + +For the path to the structured grid solver, set the `D3D_4_BIN` environment +variable. In PowerShell again: + +``` +(snld3d) > $env:D3D_4_BIN = "\path\to\SNL-Delft3D-CEC\src\bin" +``` + +Then move to the directory containing `comparison.py` and call the script using +Python: + +``` +(snld3d) > python comparison.py +``` + +If successful, the report files (and images) will be placed into a +sub-directory called `comparison_report`. To avoid repeating simulations, the +Delft3D simulations, and a copy of their case study parameters, are stored in +a sub-directory based on the model type. For the flexible mesh model this is +`fm/runs` and for the structured grid model it's `structured/runs`. If +either Delft3D solver is updated, ensure to delete or move these folders, so +that new simulations are conducted. + +By default, the study is conducted using just one CPU thread. To reduce +simulation time of the `fm` model, assuming additional capacity is available, +increase the number of utilised threads using the `--threads` optional argument: + +``` +(snld3d) > python comparison.py --threads 8 +``` + +Note that this study takes a considerable amount of wall-clock time to +complete. To run the simulations at lower resolution (and, therefore, more +rapidly), use the `--grid-resolution` optional argument. For example: + +``` +(snld3d) > python comparison.py --threads 8 --grid-resolution 0.25 +``` + +Pre-calculated results of the study at the default resolution of 0.0625m is +available in the [online documentation][110]. + ## Documentation API documentation, which describes the classes and functions used in the @@ -395,3 +462,4 @@ Retrieved 24 January 2022, from https://www.grc.nasa.gov/www/wind/valid/tutorial [116]: https://github.com/SNL-WaterPower/SNL-Delft3D-CEC [117]: https://www.deltares.nl/en/software/delft3d-4-suite/ [118]: https://github.com/Data-Only-Greater/SNL-Delft3D-CEC-Verify/releases/latest +[119]: https://github.com/lierdakil/pandoc-crossref diff --git a/docs/_assets/gh-pages-redirect.html b/docs/_assets/gh-pages-redirect.html index 4e0c61b..1b213ca 100644 --- a/docs/_assets/gh-pages-redirect.html +++ b/docs/_assets/gh-pages-redirect.html @@ -3,7 +3,7 @@ Redirecting to latest version - - + + diff --git a/docs/conf.py b/docs/conf.py index e8b4b18..32eec82 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ author = 'Mathew Topper' # The full version, including alpha/beta/rc tags -release = '0.5.3' +release = '0.6.0' # -- General configuration --------------------------------------------------- @@ -57,7 +57,7 @@ smv_remote_whitelist = r'^(origin)$' smv_tag_whitelist = r'^v(\d+\.\d+\.\d+)$' # r'^v(?!0.4.0|0.4.1|0.4.2)\d+\.\d+\.\d+$' smv_released_pattern = r'^refs/tags/.*$' -smv_latest_version = 'v0.5.3' +smv_latest_version = 'v0.6.0' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/validation/comparison/report.rst b/docs/validation/comparison/report.rst new file mode 100644 index 0000000..1e7f97a --- /dev/null +++ b/docs/validation/comparison/report.rst @@ -0,0 +1,167 @@ +Model Comparison (Windows) +========================== + +1 Introduction +-------------- + +This is a comparison of the performance of simulations of the Mycek +flume experiment (Mycek et al. 2014) using the flexible mesh (FM) and +structured grid solvers for Delft3D. The simulation settings are +mirrored between the two methods as much as possible. The chosen grid +resolution for this study is 0.0625m. Axial and radial velocities in the +horizontal plane intersecting the turbine hub will be examined. + +.. _sec:axial: + +2 Axial Velocity Comparison +--------------------------- + +This section compares axial velocities between the FM and structured +grid models. Figs. 1, 2 show the axial velocity over the horizontal +plane intersecting the turbine hub for the FM and structured gird +models, respectively. The units are non-dimensionalised by the +free-stream velocity, measured at the hub location without the presence +of the turbine. If :math:`u` is the dimensional velocity, and +:math:`u_\infty` is the dimensional free stream velocity, then the +normalized velocity :math:`u^* = u / u_\infty`. Note the observable +difference in the wake velocities immediately downstream of the turbine +between the two simulations. + +.. figure:: turb_z_u_fm.png + :alt: Figure 1: Axial velocity normalised by the free stream velocity + for the fm model type + :name: fig:turb_z_u_fm + :width: 3.64in + + Figure 1: Axial velocity normalised by the free stream velocity for + the fm model type + +.. figure:: turb_z_u_structured.png + :alt: Figure 2: Axial velocity normalised by the free stream velocity + for the structured model type + :name: fig:turb_z_u_structured + :width: 3.64in + + Figure 2: Axial velocity normalised by the free stream velocity for + the structured model type + +Fig. 3 shows the error between the non-dimensional axial velocities of +the structured grid and FM models, relative to the maximum value within +the two simulations. Three main areas of difference are revealed, the +increased deficit in the near wake for the structured model, the reduced +deficit of the structured model in the far wake and the increased +acceleration around the edges of the turbine of the structured model. + +.. figure:: turb_z_u_diff.png + :alt: Figure 3: Relative error in normalised axial velocity between + the structured and fm models + :name: fig:turb_z_u_diff + :width: 3.64in + + Figure 3: Relative error in normalised axial velocity between the + structured and fm models + +Comparing the non-dimensional centerline velocities alongside the +experimental data (published in (Mycek et al. 2014)) in fig. 4, confirms +the behavior in the near and far wake shown in fig. 3. Generally, the +structured model performs better in the near wake compared to the +experimental data, however the performance in the far wake is better for +the FM model, where the wake has decayed less. Nonetheless, neither +model captures the experimental measurements well for the whole +centerline. + +.. figure:: transect_u.png + :alt: Figure 4: Comparison of the normalised turbine centerline + velocity. Experimental data reverse engineered from (Mycek et al. + 2014, fig. 11a). + :name: fig:transect_u + :width: 4in + + Figure 4: Comparison of the normalised turbine centerline velocity. + Experimental data reverse engineered from (Mycek et al. 2014, fig. + 11a). + +.. _sec:radial: + +3 Radial Velocity Comparison +---------------------------- + +This section compares radial velocities between the FM and structured +grid models. Figs. 5, 6 show the radial velocity over the horizontal +plane intersecting the turbine hub for the FM and structured gird +models, respectively. The units are non-dimensionalized by the +free-stream velocity, (in the axial direction) measured at the hub +location without the presence of the turbine. If :math:`v` is the +dimensional velocity, then the normalized velocity +:math:`v^* = v / u_\infty`. Note the increased radial velocities +recorded for the structured grid compared to the FM simulation. + +.. figure:: turb_z_v_fm.png + :alt: Figure 5: Radial velocity normalised by the free stream + velocity for the fm model type + :name: fig:turb_z_v_fm + :width: 3.64in + + Figure 5: Radial velocity normalised by the free stream velocity for + the fm model type + +.. figure:: turb_z_v_structured.png + :alt: Figure 6: Radial velocity normalised by the free stream + velocity for the structured model type + :name: fig:turb_z_v_structured + :width: 3.64in + + Figure 6: Radial velocity normalised by the free stream velocity for + the structured model type + +Fig. 7 shows the error between the non-dimensional radial velocities of +the structured grid and FM models, relative to the maximum value within +the two simulations. The largest errors are seen upstream of the +turbine, while smaller errors are seen downstream of the turbine. The +errors in the radial flow are also much higher than for the axial flow, +with the maximum error in radial velocity being 0.2425, while the error +is 0.08593 for the axial velocity (from fig. 3). + +.. figure:: turb_z_v_diff.png + :alt: Figure 7: Relative error in normalised radial velocity between + the structured and fm models + :name: fig:turb_z_v_diff + :width: 3.64in + + Figure 7: Relative error in normalised radial velocity between the + structured and fm models + +4 Conclusion +------------ + +Comparison of simulations of the 2014 Mycek flume experiment (Mycek et +al. 2014) using the flexible mesh (FM) and structured grid solvers for +Delft3D, reveals significant differences. As seen in sec. 2, differences +in the axial velocities between the two methods were seen in the near +wake, far wake, and at the turbine edges. When comparing to the +experimental data, as in fig. 3, it was observed that the structured +grid simulation performs better in the near wake, while the FM +simulation is better in the far wake. In sec. 3, radial velocities were +compared with differences seen immediately upstream and downstream of +the turbine (see fig. 7). Notably, the maximum relative errors between +the two simulations were much larger for the radial velocities than then +axial velocities, 0.2425 and 0.08593 respectively. This discrepancy may +account for some of the differences seen in the axial flows, although +the underlying mechanisms are not yet known. Other factors may also be +contributing, including interpretation of the simulation parameters or +selection of the time step for the structured grid simulations. + +References +---------- + +.. container:: references csl-bib-body hanging-indent + :name: refs + + .. container:: csl-entry + :name: ref-mycek2014 + + Mycek, Paul, Benoît Gaurier, Grégory Germain, Grégory Pinon, and + Elie Rivoalen. 2014. “Experimental Study of the Turbulence + Intensity Effects on Marine Current Turbines Behaviour. Part I: + One Single Turbine.” *Renewable Energy* 66: 729–46. + https://doi.org/10.1016/j.renene.2013.12.036. diff --git a/docs/validation/comparison/transect_u.png b/docs/validation/comparison/transect_u.png new file mode 100644 index 0000000..9bdeb27 Binary files /dev/null and b/docs/validation/comparison/transect_u.png differ diff --git a/docs/validation/comparison/turb_z_u_diff.png b/docs/validation/comparison/turb_z_u_diff.png new file mode 100644 index 0000000..fa21a31 Binary files /dev/null and b/docs/validation/comparison/turb_z_u_diff.png differ diff --git a/docs/validation/comparison/turb_z_u_fm.png b/docs/validation/comparison/turb_z_u_fm.png new file mode 100644 index 0000000..2bd2be1 Binary files /dev/null and b/docs/validation/comparison/turb_z_u_fm.png differ diff --git a/docs/validation/comparison/turb_z_u_structured.png b/docs/validation/comparison/turb_z_u_structured.png new file mode 100644 index 0000000..ae05e2c Binary files /dev/null and b/docs/validation/comparison/turb_z_u_structured.png differ diff --git a/docs/validation/comparison/turb_z_v_diff.png b/docs/validation/comparison/turb_z_v_diff.png new file mode 100644 index 0000000..55dbeda Binary files /dev/null and b/docs/validation/comparison/turb_z_v_diff.png differ diff --git a/docs/validation/comparison/turb_z_v_fm.png b/docs/validation/comparison/turb_z_v_fm.png new file mode 100644 index 0000000..6ab145a Binary files /dev/null and b/docs/validation/comparison/turb_z_v_fm.png differ diff --git a/docs/validation/comparison/turb_z_v_structured.png b/docs/validation/comparison/turb_z_v_structured.png new file mode 100644 index 0000000..2db2b02 Binary files /dev/null and b/docs/validation/comparison/turb_z_v_structured.png differ diff --git a/docs/validation/index.rst b/docs/validation/index.rst index b67aaac..3e590be 100644 --- a/docs/validation/index.rst +++ b/docs/validation/index.rst @@ -18,3 +18,12 @@ Structured :maxdepth: 4 structured/linux/report + + +Comparison +---------- + +.. toctree:: + :maxdepth: 4 + + comparison/report diff --git a/examples/comparison.py b/examples/comparison.py new file mode 100644 index 0000000..7b67c16 --- /dev/null +++ b/examples/comparison.py @@ -0,0 +1,566 @@ +# -*- coding: utf-8 -*- + +import os +import uuid +import platform +from pathlib import Path +from dataclasses import replace + +import numpy as np +import matplotlib +import matplotlib.pyplot as plt + +from snl_d3d_cec_verify import (MycekStudy, + Report, + Result, + LiveRunner, + Template, + Validate) +from snl_d3d_cec_verify.result import get_normalised_data +from snl_d3d_cec_verify.text import Spinner + +matplotlib.rcParams.update({'font.size': 8}) + + +def main(grid_resolution, omp_num_threads): + + # Set reporting times + sigma = int(2 / grid_resolution) + + report = Report(79, "%d %B %Y") + report_dir = Path("comparison_report") + report_dir.mkdir(exist_ok=True, parents=True) + + cases = {} + u_infty = {} + results = {} + + template_types = ["fm", "structured"] + + # Run stage + for template_type in template_types: + + d3d_bin_path = None + kwargs = {"dx": grid_resolution, + "dy": grid_resolution, + "sigma": sigma, + "restart_interval": 600} + + # Choose options based on the template type + if template_type == "fm": + + bin_var = 'D3D_FM_BIN' + kwargs["stats_interval"] = 240 / (sigma ** 2) + + elif template_type == "structured": + + bin_var = 'D3D_4_BIN' + + # Set time step based on flexible mesh runs + dt_init_map = {1.0: 0.5, + 0.5: 0.25, + 0.25: 0.1, + 0.125: 0.0375, + 0.0625: 0.0125} + + if grid_resolution not in dt_init_map: + raise ValueError(f"Grid resolution '{grid_resolution}' " + "not valid.") + + kwargs["dt_init"] = dt_init_map[grid_resolution] + + cases[template_type] = MycekStudy(**kwargs) + template = Template(template_type) + + run_directory = Path(template_type) / "runs" + run_directory.mkdir(exist_ok=True, parents=True) + + # Run without turbines + no_turb_case = replace(cases[template_type], simulate_turbines=False) + no_turb_dir = find_project_dir(run_directory, no_turb_case) + result = None + + if no_turb_dir is not None: + try: + result = Result(no_turb_dir) + print("Loading pre-existing simulation at path " + f"'{no_turb_dir}'") + except FileNotFoundError: + pass + + if result is None: + + no_turb_dir = get_unique_dir(run_directory) + + if d3d_bin_path is None: + d3d_bin_path = get_env(bin_var) + print(f'Setting {template_type} bin folder path to ' + f'{d3d_bin_path}') + + print(f"Simulating {template_type} model without turbine") + + # Use the LiveRunner class to get real time feedback from the + # Delft3D calculation + runner = LiveRunner(d3d_bin_path, + omp_num_threads=omp_num_threads) + + # Make template and record case + no_turb_dir.mkdir() + template(no_turb_case, no_turb_dir) + case_path = no_turb_dir / "case.yaml" + no_turb_case.to_yaml(case_path) + + with Spinner() as spin: + for line in runner(no_turb_dir): + spin(line) + + result = Result(no_turb_dir) + + u_infty_ds = result.faces.extract_turbine_centre(-1, no_turb_case) + u_infty[template_type] = u_infty_ds["$u$"].values.take(0) + + # Run with turbines + turb_case = cases[template_type] + turb_dir = find_project_dir(run_directory, turb_case) + result = None + + if turb_dir is not None: + try: + result = Result(turb_dir) + print(f"Loading pre-existing simulation at path '{turb_dir}'") + except FileNotFoundError: + pass + + if result is None: + + turb_dir = get_unique_dir(run_directory) + + if d3d_bin_path is None: + d3d_bin_path = get_env(bin_var) + print(f'Setting {template_type} bin folder path to ' + f'{d3d_bin_path}') + + print(f"Simulating {template_type} model with turbine") + + # Use the LiveRunner class to get real time feedback from the + # Delft3D calculation + runner = LiveRunner(d3d_bin_path, + omp_num_threads=omp_num_threads) + + # Make template and record case + turb_dir.mkdir() + template(turb_case, turb_dir) + case_path = turb_dir / "case.yaml" + turb_case.to_yaml(case_path) + + with Spinner() as spin: + for line in runner(turb_dir): + spin(line) + + result = Result(turb_dir) + + results[template_type] = result + + print("Post processing...") + + section = "Introduction" + report.content.add_heading(section) + + text = ("This is a comparison of the performance of simulations of the " + "Mycek flume experiment [@mycek2014] using the flexible mesh " + "(FM) and structured grid solvers for Delft3D. The simulation " + "settings are mirrored between the two methods as much as " + "possible. The chosen grid resolution for this study is " + f"{grid_resolution}m. Axial and radial velocities in the " + "horizontal plane intersecting the turbine hub will be examined.") + report.content.add_text(text) + + section = "Axial Velocity Comparison" + report.content.add_heading(section, label="sec:axial") + + validate = Validate(turb_case) + turb_zs = {} + unorms = {} + maxus = [] + + plot_names = [] + captions = [] + fig_labels = [] + + for template_type in template_types: + + case = cases[template_type] + result = results[template_type] + turb_z = result.faces.extract_turbine_z(-1, case) + unorm = get_normalised_data(turb_z["$u$"], u_infty[template_type]) + + turb_zs[template_type] = turb_z + unorms[template_type] = unorm + + fig, ax = plt.subplots(figsize=(4, 2.75), dpi=300) + unorm.plot(x="$x$", y="$y$", vmin=0.55, vmax=1.05) + + plot_name = f"turb_z_u_{template_type}" + plot_file_name = f"{plot_name}.png" + plot_names.append(plot_file_name) + plot_path = report_dir / plot_name + fig.savefig(plot_path, bbox_inches='tight') + + # Add figure caption + caption = ("Axial velocity normalised by the free stream velocity " + f"for the {template_type} model type") + captions.append(caption) + + fig_label = f"fig:{plot_name}" + fig_labels.append(fig_label) + + # Collect maximimum u* + maxus.append(unorm.max()) + + label_text = "; ".join([f"@{x.capitalize()}" for x in fig_labels]) + label_text = f"[{label_text}]" + text = ("This section compares axial velocities between the FM and " + f"structured grid models. {label_text} show the axial velocity " + "over the horizontal plane intersecting the turbine hub for the " + "FM and structured gird models, respectively. The " + "units are non-dimensionalised by the free-stream velocity, " + "measured at the hub location without the presence of the " + "turbine. If $u$ is the dimensional velocity, and $u_\infty$ is " + "the dimensional free stream velocity, then the normalized " + "velocity $u^* = u / u_\infty$. Note the observable difference " + "in the wake velocities immediately downstream of the turbine " + "between the two simulations.") + report.content.add_text(text) + + for plot_name, caption, fig_label in zip(plot_names, captions, fig_labels): + report.content.add_image(plot_name, + caption, + label=fig_label, + width="3.64in") + + # Plot the relative error + maxu = max(maxus) + diffu = (unorms["structured"] - unorms["fm"]) / maxu + maxdiffu = np.fabs(diffu.values).max() + + fig, ax = plt.subplots(figsize=(4, 2.75), dpi=300) + diffu.plot(ax=ax, + x="$x$", + y="$y$", + cbar_kwargs={"label": "$u* / u*_{\\mathrm{max}}$"}) + + circle_rad = 6 + ax.plot(6.1, 3.46, 'o', + ms=circle_rad * 2, mec='k', mfc='none', mew=1) + ax.annotate('Acceleration', xy=(6, 3.4), xytext=(30, 30), + textcoords='offset points', color='k', + arrowprops=dict(arrowstyle=('simple,' + 'head_width=0.7,' + 'head_length=0.8,' + 'tail_width=0.1'), + lw=0.2, + facecolor='k', + shrinkB=circle_rad * 2)) + + ax.annotate('Near wake', xy=(7, 3), xytext=(-20, -60), + textcoords='offset points', color='k', + arrowprops=dict(arrowstyle=('simple,' + 'head_width=0.7,' + 'head_length=0.8,' + 'tail_width=0.1'), + lw=0.2, + facecolor='k')) + + ax.annotate('Far wake', xy=(15, 3), xytext=(-20, -60), + textcoords='offset points', color='k', + arrowprops=dict(arrowstyle=('simple,' + 'head_width=0.7,' + 'head_length=0.8,' + 'tail_width=0.1'), + lw=0.2, + facecolor='k')) + + plot_name = "turb_z_u_diff" + plot_file_name = f"{plot_name}.png" + plot_path = report_dir / plot_file_name + fig.savefig(plot_path, bbox_inches='tight') + fig_label_diffu = f"fig:{plot_name}" + + text = (f"[@{fig_label_diffu.capitalize()}] shows the error between the " + "non-dimensional axial velocities of the structured grid and FM " + "models, relative to the maximum value within the two " + "simulations. Three main areas of difference are revealed, the " + "increased deficit in the near wake for the structured model, the " + "reduced deficit of the structured model in the far wake and the " + "increased acceleration around the edges of the turbine of the " + "structured model.") + report.content.add_text(text) + + # Add figure with caption + caption = ("Relative error in normalised axial velocity between the " + "structured and fm models") + report.content.add_image(plot_file_name, + caption, + label=fig_label_diffu, + width="3.64in") + + # Centerline velocity + transect = validate[0] + transect_fm = results["fm"].faces.extract_z(-1, **transect) + transect_structured = results["structured"].faces.extract_z(-1, **transect) + transect_true = transect.to_xarray() + + transect_fm_unorm = get_normalised_data(transect_fm["$u$"], u_infty["fm"]) + transect_structured_unorm = get_normalised_data(transect_structured["$u$"], + u_infty["structured"]) + transect_true_unorm = get_normalised_data(transect_true, 0.8) + + fig, ax = plt.subplots(figsize=(4, 2.75), dpi=300) + transect_fm_unorm.plot(ax=ax, x="$x$", label='fm') + transect_structured_unorm.plot(ax=ax, x="$x$", label='structured') + transect_true_unorm.plot(ax=ax, x="$x$", label='experiment') + ax.legend() + ax.grid() + ax.set_title("") + + plot_name = "transect_u" + plot_file_name = f"{plot_name}.png" + plot_path = report_dir / plot_file_name + fig.savefig(plot_path, bbox_inches='tight') + fig_label_transect = f"fig:{plot_name}" + + text = ("Comparing the non-dimensional centerline velocities alongside " + "the experimental data (published in [@mycek2014]) in " + f"[@{fig_label_transect}], confirms the behavior in the near and " + f"far wake shown in [@{fig_label_diffu}]. Generally, the " + "structured model performs better in the near wake compared to " + "the experimental data, however the performance in the far wake " + "is better for the FM model, where the wake has decayed less. " + "Nonetheless, neither model captures the experimental " + "measurements well for the whole centerline.") + report.content.add_text(text) + + # Add figure with caption + caption = ("Comparison of the normalised turbine centerline velocity. " + "Experimental data reverse engineered from [@mycek2014, fig. " + f"{transect.attrs['figure']}].") + report.content.add_image(plot_file_name, + caption, + label=fig_label_transect, + width="4in") + + # Radial velocity + section = "Radial Velocity Comparison" + report.content.add_heading(section, label="sec:radial") + + vnorms = {} + maxvs = [] + + plot_names = [] + captions = [] + fig_labels = [] + + for template_type in template_types: + + turb_z = turb_zs[template_type] + vnorm = get_normalised_data(turb_z["$v$"], u_infty[template_type]) + vnorms[template_type] = vnorm + + fig, ax = plt.subplots(figsize=(4, 2.75), dpi=300) + vnorm.plot(x="$x$", y="$y$", vmin=-0.059, vmax=0.059, cmap='RdBu_r') + + plot_name = f"turb_z_v_{template_type}" + plot_file_name = f"{plot_name}.png" + plot_names.append(plot_file_name) + plot_path = report_dir / plot_file_name + fig.savefig(plot_path, bbox_inches='tight') + + caption = ("Radial velocity normalised by the free stream velocity " + f"for the {template_type} model type") + captions.append(caption) + + fig_label = f"fig:{plot_name}" + fig_labels.append(fig_label) + + # Collect maximimum u* + maxvs.append(vnorm.max()) + + label_text = "; ".join([f"@{x.capitalize()}" for x in fig_labels]) + label_text = f"[{label_text}]" + text = ("This section compares radial velocities between the FM and " + f"structured grid models. {label_text} show the radial velocity " + "over the horizontal plane intersecting the turbine hub for the " + "FM and structured gird models, respectively. The units are " + "non-dimensionalized by the free-stream velocity, (in the axial " + "direction) measured at the hub location without the presence of " + "the turbine. If $v$ is the dimensional velocity, then the " + "normalized velocity $v^* = v / u_\infty$. Note the increased " + "radial velocities recorded for the structured grid compared to " + "the FM simulation.") + report.content.add_text(text) + + for plot_name, caption, fig_label in zip(plot_names, captions, fig_labels): + report.content.add_image(plot_name, + caption, + label=fig_label, + width="3.64in") + + # Plot the relative error + maxv = max(maxvs) + diffv = (vnorms["structured"] - vnorms["fm"]) / maxv + maxdiffv = np.fabs(diffv.values).max() + + fig, ax = plt.subplots(figsize=(4, 2.75), dpi=300) + diffv.plot(ax=ax, + x="$x$", + y="$y$", + cbar_kwargs={"label": "$v* / v*_{\\mathrm{max}}$"}) + + plot_name = "turb_z_v_diff" + plot_file_name = f"{plot_name}.png" + plot_path = report_dir / plot_file_name + fig.savefig(plot_path, bbox_inches='tight') + fig_label_diffv = f"fig:{plot_name}" + + text = (f"[@{fig_label_diffv.capitalize()}] shows the error between the " + "non-dimensional radial velocities of the structured grid and FM " + "models, relative to the maximum value within the two " + "simulations. The largest errors are seen upstream of the " + "turbine, while smaller errors are seen downstream of the " + "turbine. The errors in the radial flow are also much higher than " + "for the axial flow, with the maximum error in radial velocity " + f"being {maxdiffv:.4g}, while the error is {maxdiffu:.4g} for the " + f"axial velocity (from [@{fig_label_diffu}]).") + report.content.add_text(text) + + # Add figure with caption + caption = ("Relative error in normalised radial velocity between the " + "structured and fm models") + report.content.add_image(plot_file_name, + caption, + label=fig_label_diffv, + width="3.64in") + + # Conclusion + report.content.add_heading("Conclusion") + + text = ("Comparison of simulations of the 2014 Mycek flume experiment " + "[@mycek2014] using the flexible mesh (FM) and structured grid " + "solvers for Delft3D, reveals significant differences. As " + "seen in [@sec:axial], differences in the axial velocities " + "between the two methods were seen in the near wake, far wake, " + "and at the turbine edges. When comparing to the experimental " + f"data, as in [@{fig_label_diffu}], it was observed that the " + "structured grid simulation performs better in the near wake, " + "while the FM simulation is better in the far wake. In " + "[@sec:radial], radial velocities were compared with differences " + "seen immediately upstream and downstream of the turbine (see " + f"[@{fig_label_diffv}]). Notably, the maximum relative errors " + "between the two simulations were much larger for the radial " + f"velocities than then axial velocities, {maxdiffv:.4g} and " + f"{maxdiffu:.4g} respectively. This discrepancy may account for " + "some of the differences seen in the axial flows, although the " + "underlying mechanisms are not yet known. Other factors may also " + "be contributing, including interpretation of the simulation " + "parameters or selection of the time step for the structured grid " + "simulations.") + report.content.add_text(text) + + # Add section for the references + report.content.add_heading("References", level=2) + + # Add report metadata + os_name = platform.system() + report.title = f"Model Comparison ({os_name})" + report.date = "today" + + # Write the report to file + with open(report_dir / "report.md", "wt") as f: + for line in report: + f.write(line) + + # Convert file to docx or print report to stdout + try: + + import pypandoc + + pypandoc.convert_file(f"{report_dir / 'report.md'}", + 'docx', + outputfile=f"{report_dir / 'report.docx'}", + extra_args=['--filter=pandoc-crossref', + '-C', + '-N', + f'--resource-path={report_dir}', + '--bibliography=examples.bib', + '--reference-doc=reference.docx']) + + except ImportError: + + print(report) + + +def find_project_dir(path, case): + + path = Path(path) + files = list(Path(path).glob("**/case.yaml")) + + for file in files: + test = MycekStudy.from_yaml(file) + if test == case: return file.parent + + return None + + +def get_unique_dir(path, max_tries=1e6): + + parent = Path(path) + + for _ in range(int(max_tries)): + name = uuid.uuid4().hex + child = parent / name + if not child.exists(): return child + + raise RuntimeError("Could not find unique directory name") + + +def get_env(variable): + + env = dict(os.environ) + + if variable in env: + root = Path(env[variable].replace('"', '')) + print(f'{variable} found') + else: + raise ValueError(f'{variable} not found') + + return root.resolve() + + +def check_positive(value): + ivalue = int(value) + if ivalue <= 0: + msg = f"{value} is an invalid positive int value" + raise argparse.ArgumentTypeError(msg) + return ivalue + + +if __name__ == "__main__": + + import argparse + + parser = argparse.ArgumentParser() + + parser.add_argument('--grid-resolution', + type=float, + choices=[1.0, 0.5, 0.25, 0.125, 0.0625], + default=0.0625, + help=("grid resolution - defaults to 0.0625")) + + parser.add_argument('--threads', + type=check_positive, + default=1, + help=("number of CPU threads to utilise for the fm " + "model- defaults to 1")) + + args = parser.parse_args() + main(args.grid_resolution, args.threads) diff --git a/examples/grid_convergence.py b/examples/grid_convergence.py index eca93c3..081e795 100644 --- a/examples/grid_convergence.py +++ b/examples/grid_convergence.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import os -import shutil +import uuid import platform import warnings from pathlib import Path @@ -29,126 +29,6 @@ matplotlib.rcParams.update({'font.size': 8}) -def get_d3d_bin_path(): - - env = dict(os.environ) - - if 'D3D_BIN' in env: - root = Path(env['D3D_BIN'].replace('"', '')) - print('D3D_BIN found') - else: - root = Path("..") / "src" / "bin" - print('D3D_BIN not found') - - print(f'Setting bin folder path to {root.resolve()}') - - return root.resolve() - - -def get_u0(da, transect, factor, case=None): - - if case is not None: - da = get_reset_origin(da, (case.turb_pos_x, - case.turb_pos_y, - case.turb_pos_z)) - - da = get_normalised_dims(da, transect.attrs["$D$"]) - da = get_normalised_data(da, factor) - - return da - - -def get_gamma0(da, transect, case=None): - - if case is not None: - da = get_reset_origin(da, (case.turb_pos_x, - case.turb_pos_y, - case.turb_pos_z)) - - da = get_normalised_dims(da, transect.attrs["$D$"]) - da = get_normalised_data_deficit(da, - transect.attrs["$U_\\infty$"], - "$\gamma_0$") - - return da - - -def plot_transects(case, - validate, - result, - factor, - ustar_ax, - gamma_ax): - - for i, transect in enumerate(validate): - - transect_true = transect.to_xarray() - - # Compare transect - transect_sim = result.faces.extract_z(-1, **transect) - - # Determine plot x-axis - major_axis = f"${transect.attrs['major_axis']}^*$" - - # Create and save a u0 figure - transect_sim_u0 = get_u0(transect_sim["$u$"], - transect_true, - factor, - case) - - transect_sim_u0.plot(ax=ustar_ax[i], - x=major_axis, - label=f'{case.dx}m') - - # Create and save a gamma0 figure - transect_sim_gamma0 = get_gamma0(transect_sim["$u$"], - transect_true, - case) - - transect_sim_gamma0.plot(ax=gamma_ax[i], - x=major_axis, - label=f'{case.dx}m') - - -def get_rmse(estimated, observed): - estimated = estimated[~np.isnan(estimated)] - if len(estimated) == 0: return np.nan - observed = observed[:len(estimated)] - return np.sqrt(((estimated - observed[:len(estimated)]) ** 2).mean()) - - -def get_transect_error(case, validate, result, factor, data): - - for i, transect in enumerate(validate): - - transect_true = transect.to_xarray() - - # Compare transect - transect_sim = result.faces.extract_z(-1, **transect) - - transect_sim_u0 = get_u0(transect_sim["$u$"], - transect_true, - factor, - case) - - transect_true_u0 = get_u0(transect_true, - transect_true, - transect_true.attrs["$U_\infty$"], - case) - - # Calculate RMS error and store - rmse = get_rmse(transect_sim_u0.values, transect_true_u0.values) - data["resolution (m)"].append(case.dx) - data["Transect"].append(transect.attrs['description']) - data["RMSE"].append(rmse) - - -def get_cells(case): - top = (case.x1 - case.x0) * (case.y1 - case.y0) * case.sigma - bottom = case.dx * case.dy - return top / bottom - - def main(template_type, max_experiments, omp_num_threads): # Steps: @@ -204,7 +84,7 @@ def main(template_type, max_experiments, omp_num_threads): case_counter = 0 - run_directory = Path(template_type) / "grid_convergence_runs" + run_directory = Path(template_type) / "runs" run_directory.mkdir(exist_ok=True, parents=True) report = Report(79, "%d %B %Y") @@ -238,22 +118,27 @@ def main(template_type, max_experiments, omp_num_threads): section = f"{case.dx}m Resolution" print(section) - no_turb_dir = run_directory / f"no_turbine_{case.dx}" + no_turb_dir = find_project_dir(run_directory, no_turb_case) - if no_turb_dir.is_dir(): + if no_turb_dir is not None: try: Result(no_turb_dir) + print("Loading pre-existing simulation at path " + f"'{no_turb_dir}'") except FileNotFoundError: - shutil.rmtree(no_turb_dir) + no_turb_dir = None # Determine $U_\infty$ for case, by running without the turbine - if not no_turb_dir.is_dir(): + if no_turb_dir is None: print("Simulating without turbine") + no_turb_dir = get_unique_dir(run_directory) no_turb_dir.mkdir() template(no_turb_case, no_turb_dir) + case_path = no_turb_dir / "case.yaml" + no_turb_case.to_yaml(case_path) with Spinner() as spin: for line in runner(no_turb_dir): @@ -273,22 +158,26 @@ def main(template_type, max_experiments, omp_num_threads): message="Insufficient grids for analysis") u_infty_convergence.add_grids([(case.dx, u_infty)]) - turb_dir = run_directory / f"turbine_{case.dx}" + turb_dir = find_project_dir(run_directory, case) - if turb_dir.is_dir(): + if turb_dir is not None: try: Result(turb_dir) + print(f"Loading pre-existing simulation at path '{turb_dir}'") except FileNotFoundError: - shutil.rmtree(turb_dir) + turb_dir = None # Run with turbines - if not turb_dir.is_dir(): + if turb_dir is None: print("Simulating with turbine") + turb_dir = get_unique_dir(run_directory) turb_dir.mkdir() template(case, turb_dir) + case_path = turb_dir / "case.yaml" + case.to_yaml(case_path) with Spinner() as spin: for line in runner(turb_dir): @@ -561,6 +450,150 @@ def main(template_type, max_experiments, omp_num_threads): print(report) +def get_d3d_bin_path(): + + env = dict(os.environ) + + if 'D3D_BIN' in env: + root = Path(env['D3D_BIN'].replace('"', '')) + print('D3D_BIN found') + else: + root = Path("..") / "src" / "bin" + print('D3D_BIN not found') + + print(f'Setting bin folder path to {root.resolve()}') + + return root.resolve() + + +def find_project_dir(path, case): + + path = Path(path) + files = list(Path(path).glob("**/case.yaml")) + + for file in files: + test = MycekStudy.from_yaml(file) + if test == case: return file.parent + + return None + + +def get_unique_dir(path, max_tries=1e6): + + parent = Path(path) + + for _ in range(int(max_tries)): + name = uuid.uuid4().hex + child = parent / name + if not child.exists(): return child + + raise RuntimeError("Could not find unique directory name") + + +def get_u0(da, transect, factor, case=None): + + if case is not None: + da = get_reset_origin(da, (case.turb_pos_x, + case.turb_pos_y, + case.turb_pos_z)) + + da = get_normalised_dims(da, transect.attrs["$D$"]) + da = get_normalised_data(da, factor) + + return da + + +def get_gamma0(da, transect, case=None): + + if case is not None: + da = get_reset_origin(da, (case.turb_pos_x, + case.turb_pos_y, + case.turb_pos_z)) + + da = get_normalised_dims(da, transect.attrs["$D$"]) + da = get_normalised_data_deficit(da, + transect.attrs["$U_\\infty$"], + "$\gamma_0$") + + return da + + +def plot_transects(case, + validate, + result, + factor, + ustar_ax, + gamma_ax): + + for i, transect in enumerate(validate): + + transect_true = transect.to_xarray() + + # Compare transect + transect_sim = result.faces.extract_z(-1, **transect) + + # Determine plot x-axis + major_axis = f"${transect.attrs['major_axis']}^*$" + + # Create and save a u0 figure + transect_sim_u0 = get_u0(transect_sim["$u$"], + transect_true, + factor, + case) + + transect_sim_u0.plot(ax=ustar_ax[i], + x=major_axis, + label=f'{case.dx}m') + + # Create and save a gamma0 figure + transect_sim_gamma0 = get_gamma0(transect_sim["$u$"], + transect_true, + case) + + transect_sim_gamma0.plot(ax=gamma_ax[i], + x=major_axis, + label=f'{case.dx}m') + + +def get_rmse(estimated, observed): + estimated = estimated[~np.isnan(estimated)] + if len(estimated) == 0: return np.nan + observed = observed[:len(estimated)] + return np.sqrt(((estimated - observed[:len(estimated)]) ** 2).mean()) + + +def get_transect_error(case, validate, result, factor, data): + + for i, transect in enumerate(validate): + + transect_true = transect.to_xarray() + + # Compare transect + transect_sim = result.faces.extract_z(-1, **transect) + + transect_sim_u0 = get_u0(transect_sim["$u$"], + transect_true, + factor, + case) + + transect_true_u0 = get_u0(transect_true, + transect_true, + transect_true.attrs["$U_\infty$"], + case) + + # Calculate RMS error and store + rmse = get_rmse(transect_sim_u0.values, transect_true_u0.values) + data["resolution (m)"].append(case.dx) + data["Transect"].append(transect.attrs['description']) + data["RMSE"].append(rmse) + + +def get_cells(case): + top = (case.x1 - case.x0) * (case.y1 - case.y0) * case.sigma + bottom = case.dx * case.dy + return top / bottom + + def check_positive(value): ivalue = int(value) if ivalue <= 0: @@ -574,8 +607,8 @@ def check_positive(value): import argparse parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(help='Desired action to perform', - dest='MODEL') + subparsers = parser.add_subparsers(dest='MODEL', + required=True) parent_parser = argparse.ArgumentParser(add_help=False) parent_parser.add_argument('--experiments', diff --git a/setup.cfg b/setup.cfg index 7bfa1b9..70f2e3c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = SNL-Delft3D-CEC-Verify -version = 0.5.3 +version = 0.6.0 author = Mathew Topper author_email = mathew.topper@dataonlygreater.com description = Automated verification of SNL-Delft3D-CEC based on the 2014 Mycek experiment diff --git a/src/snl_d3d_cec_verify/cases.py b/src/snl_d3d_cec_verify/cases.py index 9aea993..4f5f761 100644 --- a/src/snl_d3d_cec_verify/cases.py +++ b/src/snl_d3d_cec_verify/cases.py @@ -2,11 +2,17 @@ from __future__ import annotations -from typing import Any, List, Optional, TypeVar, Union +from typing import Any, List, Optional, Type, TypeVar, Union from collections.abc import Sequence -from dataclasses import dataclass, field, fields +from dataclasses import asdict, dataclass, field, fields -from .types import Num +from yaml import load, dump +try: + from yaml import CSafeLoader as Loader, CSafeDumper as Dumper +except ImportError: # pragma: no cover + from yaml import SafeLoader as Loader, SafeDumper as Dumper # type: ignore + +from .types import Num, StrOrPath from ._docs import docstringtemplate # Reused compound types @@ -14,9 +20,12 @@ OneOrMany = Union[T, Sequence[T]] OneOrManyOptional = Union[Optional[T], Sequence[Optional[T]]] +# Create a generic variable that can be 'CaseStudy', or any subclass. +C = TypeVar('C', bound='CaseStudy') + @docstringtemplate -@dataclass(frozen=True) +@dataclass(eq=False, frozen=True) class CaseStudy: """ Class for defining variables for single or multiple case studies. @@ -185,6 +194,32 @@ def get_case(self, index: int = 0) -> CaseStudy: return CaseStudy(*case_values) + @classmethod + def from_yaml(cls: Type[C], path: StrOrPath) -> C: + """Create a new instance from a YAML file + + :param path: path of the existing YAML file + """ + + with open(path, 'r') as yamlfile: + raw = load(yamlfile, Loader=Loader) + + kwarg_names = [f.name for f in fields(cls) if f.init] + kwargs = {key: raw[key] for key in kwarg_names} + + return cls(**kwargs) + + def to_yaml(self, path: StrOrPath): + """Export object as a YAML file + + :param path: path of created YAML file + """ + + data = asdict(self) + + with open(path, 'w') as yamlfile: + dump(data, yamlfile, Dumper=Dumper) + def _single_index_check(self, index: int): if index not in [0, -1]: raise IndexError("index out of range") @@ -193,6 +228,24 @@ def _multi_index_check(self, index: int): if not (-1 * length <= index <= length - 1): raise IndexError("index out of range") + def __eq__(self, other: object) -> bool: + + if not isinstance(other, CaseStudy): + return NotImplemented + + other_dict = asdict(other) + + for f, v in zip(self.fields, self.values): + + other_v = other_dict[f] + + if isinstance(v, Sequence): + if tuple(v) != tuple(other_v): return False + else: + if v != other_v: return False + + return True + def __getitem__(self, item: int) -> CaseStudy: return self.get_case(item) @@ -205,7 +258,7 @@ def __len__(self) -> int: @docstringtemplate -@dataclass(frozen=True) +@dataclass(eq=False, frozen=True) class MycekStudy(CaseStudy): """Class for defining cases corresponding to the Mycek study. Subclass of :class:`.CaseStudy` with the domain and turbine position fixed. diff --git a/src/snl_d3d_cec_verify/report.py b/src/snl_d3d_cec_verify/report.py index e35bb99..92002f9 100644 --- a/src/snl_d3d_cec_verify/report.py +++ b/src/snl_d3d_cec_verify/report.py @@ -4,6 +4,7 @@ import datetime as dt import textwrap +import warnings from abc import ABC, abstractmethod from typing import List, Optional, Tuple, Type from pathlib import Path @@ -134,27 +135,41 @@ def add_text(self, text: str, wrapped: bool = True): self._body.append((text, _Paragraph)) @docstringtemplate - def add_heading(self, text: str, level: int = 1): + def add_heading(self, text: str, + level: int = 1, + label: Optional[str] = None): """Add a heading to the document. >>> report = Report() - >>> report.content.add_heading("One") + >>> report.content.add_heading("Two", level=2, label="sec:two") >>> print(report) - 1: # One + 1: ## Two {{#sec:two}} 2: :param text: Heading text :param level: Heading level, defaults to {level}. + :param label: label for the heading, must start with 'sec:' + + :raises ValueError: if ``label`` does not start with 'sec:' """ start = '#' * level + ' ' - self.add_text(start + text, wrapped=False) + + if label is None: + label = "" + elif label[:4] != "sec:": + raise ValueError("label must start with 'sec:'") + else: + label = f" {{#{label}}}" + + self.add_text(start + text + label, wrapped=False) @docstringtemplate def add_table(self, dataframe: pd.DataFrame, index: bool = True, - caption: Optional[str] = None): + caption: Optional[str] = None, + label: Optional[str] = None): """Add a table to the document, converted from a :class:`pandas:pandas.DataFrame`. @@ -162,32 +177,50 @@ def add_table(self, dataframe: pd.DataFrame, >>> a = {{"a": [1, 2], ... "b": [3, 4]}} >>> df = pd.DataFrame(a) - >>> report.content.add_table(df, index=False, caption="A table") + >>> report.content.add_table(df, + ... index=False, + ... caption="A table", + ... label="tbl:a") >>> print(report) 1: | a | b | 2: |----:|----:| 3: | 1 | 3 | 4: | 2 | 4 | 5: - 6: Table: A table + 6: Table: A table {{#tbl:a}} 7: :param dataframe: DataFrame containing the table headings and data :param index: include the DataFrame index in the table, defaults to {index} - :param caption: add a caption for the table - + :param caption: caption for the table + :param label: label for the table, requires ``caption`` to be set and + must start with 'tbl:' + + :raises ValueError: if ``label`` does not start with 'tbl:' + """ + if label is None: + label = "" + elif label[:4] != "tbl:": + raise ValueError("label must start with 'tbl:'") + elif caption is None: + warnings.warn('label is discarded if no caption is given') + label = "" + else: + label = f" {{#{label}}}" + self.add_text(dataframe.to_markdown(index=index), wrapped=False) if caption is None: return - text = "Table: " + caption + text = "Table: " + caption + label self.add_text(text, wrapped=False) def add_image(self, path: StrOrPath, caption: Optional[str] = None, + label: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None): """Add image to document, passed as path to a compatible image file. @@ -195,17 +228,22 @@ def add_image(self, path: StrOrPath, >>> report = Report() >>> report.content.add_image("high_art.png", ... caption="Probably an NFT", + ... label="fig:hart", ... width="6in", ... height="4in") >>> print(report) - 1: ![Probably an NFT](high_art.png){ width=6in height=4in } + 1: ![Probably an NFT](high_art.png){ #fig:hart width=6in height=4in } 2: :param path: path to the image file :param caption: caption for the image + :param label: label for the image, requires ``caption`` to be set and + must start with 'fig:' :param width: image width in document, including units :param height: image height in document, including units - + + :raises ValueError: if ``label`` does not start with 'fig:' + """ path = Path(path) @@ -217,10 +255,22 @@ def add_image(self, path: StrOrPath, text += f"({path})" - if width is not None or height is not None: + if label is not None: + if label[:4] != "fig:": + raise ValueError("label must start with 'fig:'") + elif caption is None: + warnings.warn('label is discarded if no caption is given') + label = None + + if (label is not None or + width is not None or + height is not None): attrs_str = "{ " + if label is not None: + attrs_str += f"#{label} " + if width is not None: attrs_str += f"width={width} " diff --git a/tests/test_cases.py b/tests/test_cases.py index aacba78..21d6408 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -1,15 +1,22 @@ # -*- coding: utf-8 -*- +from dataclasses import replace + import pytest from snl_d3d_cec_verify.cases import CaseStudy, MycekStudy @pytest.fixture -def cases(): - return CaseStudy(dx=(1, 2, 3, 4), - dy=(1, 2, 3, 4), - sigma=(1, 2, 3, 4)) +def casedef(): + return {"dx": (1, 2, 3, 4), + "dy": (1, 2, 3, 4), + "sigma": (1, 2, 3, 4)} + + +@pytest.fixture +def cases(casedef): + return CaseStudy(**casedef) def test_casestudy_fields(): @@ -83,6 +90,22 @@ def test_casestudy_get_case(cases, index): assert case.sigma == index + 1 +def test_casestudy_to_from_yaml(tmp_path, cases): + + d = tmp_path / "mock" + d.mkdir() + p = d / "test.yaml" + cases.to_yaml(p) + + assert len(list(d.iterdir())) == 1 + + test = CaseStudy.from_yaml(p) + + assert isinstance(test, CaseStudy) + assert id(test) != id(cases) + assert test == cases + + @pytest.mark.parametrize("index", [0, 1, 2, 3]) def test_casestudy_getitem(cases, index): case = cases[index] @@ -135,6 +158,20 @@ def test_casestudy_get_case_out_of_bounds_sigle(cases, index): assert "index out of range" in str(excinfo) +def test_casestudy_not_eq_other(cases): + assert cases != {"a": 1} + + +def test_casestudy_not_eq_scalar(cases): + test = replace(cases, simulate_turbines=False) + assert cases != test + + +def test_casestudy_eq_sequence(cases): + test = replace(cases, dx=(2, 3, 4, 5)) + assert cases != test + + @pytest.mark.parametrize("variable", ["x0", "x1", "y0", "y1", "bed_level"]) def test_mycekstudy_variables_error(variable): @@ -165,3 +202,28 @@ def test_mycekstudy_turb_pos_error(axis): MycekStudy(**input_dict) assert f"turb_pos_{axis}" in str(excinfo) + + +@pytest.fixture +def mycekcases(casedef): + return MycekStudy(**casedef) + + +def test_mycekstudy_to_from_yaml(tmp_path, mycekcases): + + d = tmp_path / "mock" + d.mkdir() + p = d / "test.yaml" + mycekcases.to_yaml(p) + + assert len(list(d.iterdir())) == 1 + + test = MycekStudy.from_yaml(p) + + assert isinstance(test, MycekStudy) + assert id(test) != id(cases) + assert test == mycekcases + + +def test_casestudy_mycekstudy_equality(cases, mycekcases): + assert cases == mycekcases diff --git a/tests/test_report.py b/tests/test_report.py index c68cbcc..ac1a847 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -150,6 +150,34 @@ def test_content_add_heading(content): assert text == '#' * level + ' ' + title +def test_content_add_heading_label_error(content): + + title = "Test" + level = 2 + label = "mock" + + with pytest.raises(ValueError) as excinfo: + content.add_heading(title, level=level, label=label) + + assert "must start with 'sec:'" in str(excinfo) + assert len(content._body) == 0 + + +def test_content_add_heading_label(content): + + title = "Test" + level = 2 + label = "sec:mock" + + content.add_heading(title, level=level, label=label) + + assert len(content._body) == 1 + assert content._body[0][1] is _Paragraph + + text = content._body[0][0] + assert text == '#' * level + ' ' + title + ' ' + f'{{#{label}}}' + + def test_content_add_table(content): data = {"a": [1, 2, 3], @@ -170,6 +198,58 @@ def test_content_add_table(content): assert caption_text == "Table: " + caption +def test_content_add_table_label_error(content): + + df = "mock" + caption = "test" + label = "mock" + + with pytest.raises(ValueError) as excinfo: + content.add_table(df, caption=caption, label=label) + + assert "must start with 'tbl:'" in str(excinfo) + assert len(content._body) == 0 + + +def test_content_add_table_label_no_caption(content): + + data = {"a": [1, 2, 3], + "b": [4, 5, 6]} + df = pd.DataFrame(data) + + label = "tbl:mock" + + with pytest.warns(UserWarning, match='label is discarded'): + content.add_table(df, label=label) + + assert len(content._body) == 1 + assert content._body[0][1] is _Paragraph + + table_text = content._body[0][0] + assert table_text == df.to_markdown(index=True) + + +def test_content_add_table_label(content): + + data = {"a": [1, 2, 3], + "b": [4, 5, 6]} + df = pd.DataFrame(data) + + caption = "test" + label = "tbl:mock" + content.add_table(df, caption=caption, label=label) + + assert len(content._body) == 2 + assert content._body[0][1] is _Paragraph + assert content._body[1][1] is _Paragraph + + table_text = content._body[0][0] + assert table_text == df.to_markdown(index=True) + + caption_text = content._body[1][0] + assert caption_text == "Table: " + caption + ' ' + f'{{#{label}}}' + + def test_content_add_image(content): fake_path = "some_image.jpg" @@ -195,24 +275,72 @@ def test_content_add_image_with_caption(content): assert image_text == f"![{caption}]({fake_path})" -@pytest.mark.parametrize("width, height, expected_attrs", [ - ("5in", None, "width=5in"), - (None, "10px", "height=10px"), - ("5in", "10px", "width=5in height=10px")]) +def test_content_add_image_label_error(content): + + fake_path = "some_image.jpg" + caption = "test" + label = "mock" + + with pytest.raises(ValueError) as excinfo: + content.add_image(fake_path, caption=caption, label=label) + + assert "must start with 'fig:'" in str(excinfo) + assert len(content._body) == 0 + + +def test_content_add_image_label_no_caption(content): + + fake_path = "some_image.jpg" + label = "fig:mock" + + with pytest.warns(UserWarning, match='label is discarded'): + content.add_image(fake_path, label=label) + + assert len(content._body) == 1 + assert content._body[0][1] is _Paragraph + + image_text = content._body[0][0] + assert image_text == f"![{fake_path}]({fake_path})\\" + + +def test_content_add_image_with_caption_label(content): + + fake_path = "some_image.jpg" + caption = "my caption" + label = "fig:mock" + content.add_image(fake_path, caption, label=label) + + assert len(content._body) == 1 + assert content._body[0][1] is _Paragraph + + image_text = content._body[0][0] + assert image_text == f"![{caption}]({fake_path}){{ #{label} }}" + + +@pytest.mark.parametrize("label, width, height, expected_attrs", [ + (None, "5in", None, "width=5in"), + ("fig:mock", None, "10px", "#fig:mock height=10px"), + (None, "5in", "10px", "width=5in height=10px")]) def test_content_add_image_with_dimensions(content, + label, width, height, expected_attrs): fake_path = "some_image.jpg" - content.add_image(fake_path, width=width, height=height) + caption = "test" + content.add_image(fake_path, + caption=caption, + label=label, + width=width, + height=height) assert len(content._body) == 1 assert content._body[0][1] is _Paragraph image_text = content._body[0][0] - assert image_text == (f"![{fake_path}]({fake_path}){{ {expected_attrs} " - "}\\") + assert image_text == (f"![{caption}]({fake_path}){{ {expected_attrs} " + "}") def test_content_clear(content, text):