diff --git a/docs/source/usage/folders.rst b/docs/source/usage/folders.rst index aea0111a..922adb24 100644 --- a/docs/source/usage/folders.rst +++ b/docs/source/usage/folders.rst @@ -22,8 +22,12 @@ The following is a scheme of the JADE folder structure: | | | |----- serpent | | | | |----- .i | | | |----- benchmark_metadata.json + | | | | | |----- - | | |----- ... + | | | |----- ... + | | | + | | |----- metadata.json + | | | |----- ... | |----- TypicalMaterials | diff --git a/src/jade/config/atlas_config.py b/src/jade/config/atlas_config.py index 4d107036..80e868be 100644 --- a/src/jade/config/atlas_config.py +++ b/src/jade/config/atlas_config.py @@ -1,16 +1,79 @@ from __future__ import annotations from dataclasses import dataclass +from enum import Enum +from pathlib import Path -from jade.post.plotter import PlotType +import yaml + +from jade.helper.aux_functions import PathLike @dataclass class ConfigAtlasProcessor: + benchmark: str plots: list[PlotConfig] + @classmethod + def from_yaml(cls, config_file: PathLike) -> ConfigAtlasProcessor: + """Create a ConfigExcelProcessor object from a yaml file. + + Parameters + ---------- + config_file : PathLike + path to the configuration file + + Returns + ------- + ConfigAtlasProcessor + The configuration object + """ + with open(config_file) as f: + cfg = yaml.safe_load(f) + + benchmark = Path(config_file).name.split(".")[0] + + plots = [] + for table_name, dict in cfg.items(): + plots.append(PlotConfig.from_dict(dict, table_name)) + + return cls(benchmark=benchmark, plots=plots) + @dataclass class PlotConfig: + name: str results: list[int | str] plot_type: PlotType + title: str + x_label: str + y_labels: list[str] + x: str + y: str + # optionals + expand_runs: bool = True + + @classmethod + def from_dict(cls, dictionary: dict, name: str) -> PlotConfig: + return cls( + name=name, + results=dictionary["results"], + plot_type=PlotType(dictionary["plot_type"]), + title=dictionary["title"], + x_label=dictionary["x_label"], + y_labels=dictionary["y_labels"], + x=dictionary["x"], + y=dictionary["y"], + expand_runs=dictionary.get("expand_runs", True), + ) + + +class PlotType(Enum): + BINNED = "binned" + RATIO = "ratio" + EXP = "exp points" + EXP_GROUP = "exp points group" + CE_EXP_GROUP = "exp points group CE" + DISCRETE_EXP = "discrete exp points" + GROUPED_BARS = "grouped bars" + WAVES = "waves" diff --git a/src/jade/post/atlas.py b/src/jade/post/atlas.py new file mode 100644 index 00000000..4df5d623 --- /dev/null +++ b/src/jade/post/atlas.py @@ -0,0 +1,220 @@ +import logging +import os + +# import win32com.client +# import aspose.words +import docx +import pandas as pd +from docx.enum.table import WD_ALIGN_VERTICAL +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement, parse_xml +from docx.oxml.ns import nsdecls, qn +from docx.shared import Inches + +from jade.helper.aux_functions import PathLike + + +class Atlas: + def __init__(self, template: PathLike, name: str): + """ + Atlas of plots for post-processing + + Parameters + ---------- + template : PathLike + path to the word template file. + name : str + name of the atlas file. + + Returns + ------- + None. + + """ + self.name = name # Name of the Atlas (from libraries) + # Open The Atlas template + doc = docx.Document(template) + doc.add_heading("JADE ATLAS: " + name, level=0) + self.outname = "atlas_" + name # Name for the outfile + self.doc = doc # Word Document + + def insert_img(self, img, width=Inches(7.5)): + """Insert an image in the word document + + Parameters + ---------- + img : _type_ + _description_ + width : _type_, optional + _description_, by default Inches(7.5) + """ + self.doc.add_picture(img, width=width) + last_paragraph = self.doc.paragraphs[-1] + last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # def insert_df( + # self, + # df, + # caption=None, + # highlight=False, + # tablestyle=None, # , template_idx=None, + # ): + # """ + # Inser a dataframe as a table in a Word file + + # Parameters + # ---------- + # df : pd.DataFrame + # dataframe to insert. + # caption : str, optional + # caption of the table. The default is None + # highlight : bool, optional + # Very specific for stress assessment. The default is False. + # # template_idx : int, optional + # # index of the template table to use. The default is None + # tablestyle : str, optional + # table word style to apply. The default is None + + # Returns + # ------- + # table : docx.Table + # table inserted. + + # """ + # # Create the table or inherit a template + # template_idx = None # other possibilities not implemented anymore + # if template_idx is None: + # table = self.doc.add_table(1, len(df.columns)) + # else: + # template = self.template_tables[template_idx] + # table = template.insert(self.doc) + + # # Assign style if provided + # if table.style is not None: + # table.style = tablestyle + + # # If template is not provided, the header row must be filled + # if template_idx is None: + # # add the header rows. + # for j in range(df.shape[-1]): + # table.cell(0, j).text = df.columns[j] + + # # Add the rest of the data frame + # # val = df.values + # for i, (idx, row) in enumerate(df.iterrows()): + # # Understand is safety margin is barely acceptable + # flag_almost = False + # try: + # sm = float(row["Safety Margin"]) + # if sm > 1 and sm < 1.1: + # flag_almost = True + # except KeyError: + # pass + # except ValueError: + # pass + # except TypeError: + # # cannot convert to float + # pass + + # row_cells = table.add_row().cells + # for j, item in enumerate(row): + # cell = row_cells[j] + # cell.text = str(item) + # cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER + # for par in cell.paragraphs: + # par.style = "Table" + # if highlight is not None: + # if cell.text == "NOK": + # self._highlightCell(cell) + # elif cell.text == "OK" and flag_almost: + # self._highlightCell(cell, color="FFFF46") + + # if caption is not None: + # paragraph = self.doc.add_paragraph("Table ", style="Didascalia") + # self._wrapper(paragraph, "table") + # paragraph.add_run(" - " + caption) + # # paragraph = doc.add_paragraph('Figure Text', style='Didascalia') + + # return table + + def save(self, outpath, pdfprint=True): + """ + Save word atlas and possibly export PDF + + Parameters + ---------- + outpath : str/path + path to export the atlas + pdfprint : Boolean, optional + If True export also in PDF format + + Returns + ------- + None. + + """ + outpath_word = os.path.join(outpath, self.outname + ".docx") + # outpath_pdf = os.path.join(outpath, self.outname + ".pdf") + if len(outpath_word) > 259: + logging.warning( + "The path to the word document is too long, the file will be truncated" + ) + outpath_word = outpath_word[:254] + ".docx" + + try: + self.doc.save(outpath_word) + except FileNotFoundError as e: + # If there is still an error it may be due to special char + # print the original exception + print(" The following is the original exception:") + print(e) + print( + "\n it may be due to invalid characters in the file name or a path too long" + ) + + # Remove PDF printing. If required, word document can be saved manually. + if pdfprint: + pass + + @staticmethod + def _wrapper(paragraph, ptype): + """ + Wrap a paragraph in order to add cross reference + + Parameters + ---------- + paragraph : docx.Paragraph + image to wrap. + ptype : str + type of paragraph to wrap + + Returns + ------- + None. + + """ + if ptype == "table": + instruction = " SEQ Table \\* ARABIC" + elif ptype == "figure": + instruction = " SEQ Figure \\* ARABIC" + else: + raise ValueError(ptype + " is not a supported paragraph type") + + run = run = paragraph.add_run() + r = run._r + fldChar = OxmlElement("w:fldChar") + fldChar.set(qn("w:fldCharType"), "begin") + r.append(fldChar) + instrText = OxmlElement("w:instrText") + instrText.text = instruction + r.append(instrText) + fldChar = OxmlElement("w:fldChar") + fldChar.set(qn("w:fldCharType"), "end") + r.append(fldChar) + + # @staticmethod + # def _highlightCell(cell, color="FBD4B4"): + # shading_elm_1 = parse_xml( + # r'' + # ) + # cell._tc.get_or_add_tcPr().append(shading_elm_1) diff --git a/src/jade/post/atlas_processor.py b/src/jade/post/atlas_processor.py new file mode 100644 index 00000000..597d1c4a --- /dev/null +++ b/src/jade/post/atlas_processor.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +from copy import deepcopy +from pathlib import Path + +from jade.config.atlas_config import ConfigAtlasProcessor +from jade.helper.aux_functions import PathLike, print_code_lib +from jade.helper.constants import CODE +from jade.post.atlas import Atlas +from jade.post.excel_processor import ExcelProcessor +from jade.post.plotter import PlotFactory + + +class AtlasProcessor: + def __init__( + self, + raw_root: PathLike, + atlas_folder_path: PathLike, + cfg: ConfigAtlasProcessor, + codelibs: list[tuple[str, str]], + ) -> None: + """Object responsible to produce the excel comparison results for a given + benchmark. + + Parameters + ---------- + raw_root : PathLike + path to the raw data folder root + atlas_folder_path : PathLike + path to the atlas folder where the results will be stored + cfg : ConfigAtlasProcessor + configuration options for the excel processor + codelibs : list[tuple[str, str]] + list of code-lib results that should be compared. The first one is + interpreted as the reference data. + """ + self.atlas_folder_path = atlas_folder_path + self.raw_root = raw_root + self.cfg = cfg + self.codelibs = codelibs + + def process(self) -> None: + """Process the atlas comparison for the given benchmark. It will produce one + atlas file comparing all requested code-lib results in each plot. + """ + # instantiate the atlas + # atlas = Atlas(self.atlas_folder_path, self.cfg.benchmark) + + for plot_cfg in self.cfg.plots: + dfs = [] + # get global df results for each code-lib + for code_tag, lib in self.codelibs: + code = CODE(code_tag) + codelib = print_code_lib(code, lib) + logging.info("Parsing reference data") + raw_folder = Path(self.raw_root, codelib, self.cfg.benchmark) + + df = ExcelProcessor._get_table_df(plot_cfg.results, raw_folder) + if plot_cfg.expand_runs: + run_dfs = [] + df = df.reset_index() + for run in df["Case"].unique(): + run_df = df[df["Case"] == run] + run_dfs.append((run, run_df)) + dfs.append((plot_cfg.name, run_dfs)) + else: + dfs.append((plot_cfg.name, df.reset_index())) + + # create the plot + if plot_cfg.expand_runs: # one plot for each case/run + for case, df in dfs: + cfg = deepcopy(plot_cfg) + cfg.name = f"{cfg.name} {case}" + plot = PlotFactory.create_plot(plot_cfg, df) + else: + plot = PlotFactory.create_plot(plot_cfg, dfs) diff --git a/src/jade/post/plotter.py b/src/jade/post/plotter.py index c2dea176..220db2af 100644 --- a/src/jade/post/plotter.py +++ b/src/jade/post/plotter.py @@ -1,14 +1,279 @@ from __future__ import annotations -from enum import Enum - - -class PlotType(Enum): - BINNED = "Binned graph" - RATIO = "Ratio graph" - EXP = "Experimental points" - EXP_GROUP = "Experimental points group" - CE_EXP_GROUP = "Experimental points group CE" - DISCRETE_EXP = "Discrete Experimental points" - GROUPED_BARS = "Grouped bars" - WAVES = "Waves" +from abc import ABC, abstractmethod + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.lines import Line2D +from matplotlib.markers import CARETDOWNBASE, CARETUPBASE +from matplotlib.ticker import AutoMinorLocator, LogLocator, MultipleLocator + +from jade.config.atlas_config import PlotConfig, PlotType + +# Color-blind saver palette +COLORS = [ + "#377eb8", + "#ff7f00", + "#4daf4a", + "#f781bf", + "#a65628", + "#984ea3", + "#999999", + "#e41a1c", + "#dede00", +] * 50 + + +class Plot(ABC): + def __init__( + self, plot_config: PlotConfig, data: list[tuple[str, pd.DataFrame]] + ) -> None: + self.cfg = plot_config + self.data = data + + def plot(self) -> tuple[Figure, Axes | list[Axes]]: + fig, axes = self._get_figure() + if isinstance(axes, Axes): + axes = [axes] + + axes[0].set_title(self.cfg.title) + axes[-1].set_xlabel(self.cfg.x_label) + + return fig, axes + + @abstractmethod + def _get_figure(self) -> tuple[Figure, Axes]: + pass + + +class BinnedPlot(Plot): + def _get_figure(self) -> tuple[Figure, Axes]: + # Set properties for the plot spacing + gridspec_kw = {"height_ratios": [4, 1, 1], "hspace": 0.13} + # Initiate plot + fig, axes = plt.subplots( + nrows=3, + ncols=1, + sharex=True, + # figsize=(18, 13.5), + gridspec_kw=gridspec_kw, + ) + + # --- Main plot --- + ax1 = axes[0] + # Ticks + subs = (0.2, 0.4, 0.6, 0.8) + ax1.set_xscale("log") + ax1.set_yscale("log") + ax1.set_ylabel(self.cfg.y_labels[0]) + ax1.xaxis.set_major_locator(LogLocator(base=10, numticks=15)) + ax1.yaxis.set_major_locator(LogLocator(base=10, numticks=15)) + ax1.xaxis.set_minor_locator(LogLocator(base=10.0, subs=subs, numticks=12)) + ax1.yaxis.set_minor_locator(LogLocator(base=10.0, subs=subs, numticks=12)) + + # --- Error Plot --- + ax2 = axes[1] + ax2.axhline(y=10, linestyle="--", color="black") + ax2.set_ylabel("1σ [%]") + ax2.set_yscale("log") + ax2.set_ylim(bottom=1, top=100) + ax2.yaxis.set_major_locator(LogLocator(base=10, numticks=15)) + ax2.yaxis.set_minor_locator(LogLocator(base=10.0, subs=subs, numticks=12)) + + # --- Comparison Plot --- + ax3 = axes[2] + ax3.axhline(y=1, linestyle="--", color="black") + ax3.set_ylabel("$T_i/R$") + ax3.yaxis.set_major_locator(MultipleLocator(0.5)) + ax3.yaxis.set_minor_locator(AutoMinorLocator(5)) + ax3.axhline(y=2, linestyle="--", color="red", linewidth=0.5) + ax3.axhline(y=0.5, linestyle="--", color="red", linewidth=0.5) + ax3.set_ylim(bottom=0.3, top=2.2) + + # Generate X axis for bin properties + oldX = np.array([0] + list(self.data[0][1][self.cfg.x])) + base = np.log(oldX[:-1]) + shifted = np.log(oldX[1:]) + newX = np.exp((base + shifted) / 2) + newX[0] = (oldX[1] + oldX[0]) / 2 + # --- Plot Data --- + for idx, (codelib, df) in enumerate(self.data): + x = np.array([0] + list(df[self.cfg.x])) + y = np.array([0] + list(df[self.cfg.y])) + + err = np.array(df["Error"]) + err_multi = np.array(y[1:]) * np.abs(err) + + # Main plot + if idx > 0: + tag = "T" + str(idx) + ": " + else: + tag = "R: " + ax1.step(x, y, label=tag + codelib, color=COLORS[idx], linestyle="--") + ax1.errorbar( + newX, + y[1:], + linewidth=0, + yerr=err_multi, + elinewidth=0.5, + color=COLORS[idx], + ) + + # Error Plot + ax2.plot( + newX, + np.array(df["Error"]) * 100, + "o", + label=codelib, + markersize=2, + color=COLORS[idx], + ) + + # Comparison + if idx > 0: + ratio = df[self.cfg.y] / self.data[0][1][self.cfg.y] + # Uniform plots actions + norm, upper, lower = _get_limits(0.5, 2, ratio, newX) + + ax3.plot(norm[0], norm[1], "o", markersize=2, color=COLORS[idx]) + ax3.scatter(upper[0], upper[1], marker=CARETUPBASE, s=50, c=COLORS[idx]) + ax3.scatter( + lower[0], + lower[1], + marker=CARETDOWNBASE, + s=50, + c=COLORS[idx], + ) + + # Build ax3 legend + leg = [ + Line2D( + [0], + [0], + marker=CARETUPBASE, + color="black", + label="> 2", + markerfacecolor="black", + markersize=8, + lw=0, + ), + Line2D( + [0], + [0], + marker=CARETDOWNBASE, + color="black", + label="< 0.5", + markerfacecolor="black", + markersize=8, + lw=0, + ), + ] + ax3.legend(handles=leg, loc="best") + + # Final operations + ax1.legend(loc="best") + + # --- Common Features --- + for ax in axes: + # Grid control + ax.grid() + ax.grid("True", which="minor", linewidth=0.25) + # Ticks + ax.tick_params(which="major", width=1.00, length=5) + ax.tick_params(which="minor", width=0.75, length=2.50) + + return fig, axes + + +class RatioPlot(Plot): + pass + + +class ExpPlot(Plot): + pass + + +class ExpGroupPlot(Plot): + pass + + +class CeExpGroupPlot(Plot): + pass + + +class DiscreteExpPlot(Plot): + pass + + +class GroupedBarsPlot(Plot): + pass + + +class WavesPlot(Plot): + pass + + +class PlotFactory: + @staticmethod + def create_plot( + plot_config: PlotConfig, data: list[tuple[str, pd.DataFrame]] + ) -> Plot: + if plot_config.plot_type == PlotType.BINNED: + return BinnedPlot(plot_config, data) + else: + raise NotImplementedError( + f"Plot type {plot_config.plot_type} not implemented" + ) + + +# Aux functions +def _get_limits(lowerlimit, upperlimit, ydata, xdata): + """ + Given an X, Y dataset and bounding y limits it returns three datasets + couples containing the sets to be normally plotted and the upper and + lower set. + + Parameters + ---------- + lowerlimit : float + bounding lower y limit. + upperlimit : float + bounding upper x limit. + ydata : list or array + Y axis data. + xdata : list or array + X axis data. + + Returns + ------- + (normalX, normalY) + x, y sets to be normally plotted + (upperX, upperY) + x, y sets that are above upperlimit + (lowerX, lowerY) + x, y sets that are below upper limit. + + """ + assert lowerlimit < upperlimit + # ensure data is array + ydata = np.array(ydata) + xdata = np.array(xdata) + # Get the three logical maps + upmap = ydata > upperlimit + lowmap = ydata < lowerlimit + normalmap = np.logical_and(np.logical_not(upmap), np.logical_not(lowmap)) + + # Apply maps to divide the original sets + normalY = ydata[normalmap] + normalX = xdata[normalmap] + + upperX = xdata[upmap] + upperY = np.full(len(upperX), upperlimit) + + lowerX = xdata[lowmap] + lowerY = np.full(len(lowerX), lowerlimit) + + return (normalX, normalY), (upperX, upperY), (lowerX, lowerY) diff --git a/src/jade/resources/AtlasTemplate.docx b/src/jade/resources/AtlasTemplate.docx new file mode 100644 index 00000000..a0572897 Binary files /dev/null and b/src/jade/resources/AtlasTemplate.docx differ diff --git a/src/jade/resources/default_cfg/benchmarks_pp/atlas/Sphere.yaml b/src/jade/resources/default_cfg/benchmarks_pp/atlas/Sphere.yaml new file mode 100644 index 00000000..4d03c1e5 --- /dev/null +++ b/src/jade/resources/default_cfg/benchmarks_pp/atlas/Sphere.yaml @@ -0,0 +1,20 @@ +Neutron Leakage flux: + results: + - Leakage neutron flux + plot_type: binned + tile: Neutron Leakage by unit lethargy + x_label: Neutron flux by unit lethargy [n/cm^2/s/MeV] + y_label: Energy [MeV] + x: Energy + y: Value +# +Photon Leakage flux: + results: + - Leakage neutron flux + plot_type: binned + tile: Neutron Leakage by unit lethargy + x_label: Neutron flux by unit lethargy [n/cm^2/s/MeV] + y_label: Energy [MeV] + x: Energy + y: Value + \ No newline at end of file diff --git a/src/jade/resources/default_cfg/benchmarks_pp/excel/Sphere.yml b/src/jade/resources/default_cfg/benchmarks_pp/excel/Sphere.yaml similarity index 100% rename from src/jade/resources/default_cfg/benchmarks_pp/excel/Sphere.yml rename to src/jade/resources/default_cfg/benchmarks_pp/excel/Sphere.yaml diff --git a/src/jade/resources/default_cfg/env_vars_cfg.yml b/src/jade/resources/default_cfg/env_vars_cfg.yml index d30890b8..3e88505a 100644 --- a/src/jade/resources/default_cfg/env_vars_cfg.yml +++ b/src/jade/resources/default_cfg/env_vars_cfg.yml @@ -5,9 +5,9 @@ openmp_threads: 8 # this controls the number of OpenMP threads to be used durin # paths to the code executables. If the codes are not installed, just leave the field empty. executables: - mcnp: path/to/mcnp6/executable # it can also be just 'mcnp6' if the executable is in the PATH - - openmc: path/to/openmc/executable # it can also be just 'openmc' if the executable is in the PATH - - serpent: path/to/serpent/executable # it can also be just 'sss2' if the executable is in the PATH - - d1s: path/to/d1s/executable # it can also be just 'd1s' if the executable is in the PATH + - openmc: path/to/openmc/executable # idem + - serpent: path/to/serpent/executable # idem + - d1s: path/to/d1s/executable # idem # run mode is either "serial" to run locally or "job" to submit to a cluster run_mode: serial diff --git a/tests/post/test_plotter.py b/tests/post/test_plotter.py new file mode 100644 index 00000000..ff4e38e1 --- /dev/null +++ b/tests/post/test_plotter.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd + +from jade.config.atlas_config import PlotConfig +from jade.post.plotter import BinnedPlot + + +class TestBinnedPlot: + def test_plot(self, tmpdir): + cfg = PlotConfig( + name="test", + results=[1, 2, 3], # dummy values + plot_type=None, # dummy value + title="test", + x_label="test", + y_labels=["label 1", "label 2", "label 3"], + x="energy", + y="value", + ) + + data1 = pd.DataFrame( + { + "energy": range(10), + "value": np.random.rand(10), + "Error": np.random.rand(10) * 0.1, + } + ) + + data2 = pd.DataFrame( + { + "energy": range(10), + "value": np.random.rand(10), + "Error": np.random.rand(10) * 0.1, + } + ) + + data3 = pd.DataFrame( + { + "energy": range(10), + "value": np.random.rand(10), + "Error": np.random.rand(10) * 0.1, + } + ) + + data = [("data1", data1), ("data2", data2), ("data3", data3)] + + plot = BinnedPlot(cfg, data) + fig, ax = plot.plot() + fig.savefig(tmpdir.join("test.png"))