diff --git a/environment.yml b/environment.yml index 2fc9532..9c671c3 100644 --- a/environment.yml +++ b/environment.yml @@ -1,25 +1,26 @@ name: floatcsep channels: - - conda-forge - - defaults + - conda-forge + - defaults dependencies: - - python <= 3.11 - - numpy - - pycsep - - dateparser - - docker-py - - flake8 - - gitpython - - h5py - - matplotlib - - pip - - pyshp - - pyyaml - - requests - - seaborn - - sphinx - - sphinx-autoapi - - sphinx-gallery - - sphinx-rtd-theme - - pytables - - xmltodict + - python <= 3.11 + - numpy + - pycsep + - dateparser + - docker-py + - flake8 + - gitpython + - h5py + - matplotlib + - pip + - pyshp + - pyyaml + - requests + - scipy + - seaborn + - sphinx + - sphinx-autoapi + - sphinx-gallery + - sphinx-rtd-theme + - pytables + - xmltodict diff --git a/examples/case_g/config.yml b/examples/case_g/config.yml index b36f4f1..82dfdca 100644 --- a/examples/case_g/config.yml +++ b/examples/case_g/config.yml @@ -20,4 +20,4 @@ model_config: models.yml test_config: tests.yml postprocess: - plot_custom: plot_script.py:main \ No newline at end of file + plot_custom: custom_plot_script.py:main \ No newline at end of file diff --git a/examples/case_g/plot_script.py b/examples/case_g/custom_plot_script.py similarity index 100% rename from examples/case_g/plot_script.py rename to examples/case_g/custom_plot_script.py diff --git a/examples/case_h/config.yml b/examples/case_h/config.yml index 846ee44..ca5c7f9 100644 --- a/examples/case_h/config.yml +++ b/examples/case_h/config.yml @@ -17,4 +17,8 @@ region_config: force_rerun: True catalog: catalog.csv model_config: models.yml -test_config: tests.yml \ No newline at end of file +test_config: tests.yml + +postprocess: + plot_catalog: False + report: custom_report.py:main \ No newline at end of file diff --git a/examples/case_h/custom_report.py b/examples/case_h/custom_report.py new file mode 100644 index 0000000..219b6d2 --- /dev/null +++ b/examples/case_h/custom_report.py @@ -0,0 +1,47 @@ +from floatcsep.report import MarkdownReport +from floatcsep.utils import timewindow2str + + +def main(experiment): + + timewindow = experiment.timewindows[-1] + timestr = timewindow2str(timewindow) + + report = MarkdownReport() + report.add_title(f"Experiment Report - {experiment.name}", "") + report.add_heading("Objectives", level=2) + + objs = [ + f"Comparison of ETAS, pyMock-Poisson and pyMock-NegativeBinomial models for the" + f"day after the Amatrice earthquake, for events with M>{min(experiment.magnitudes)}.", + ] + report.add_list(objs) + + report.add_figure( + f"Input catalog", + [ + experiment.registry.get_figure("main_catalog_map"), + experiment.registry.get_figure("main_catalog_time"), + ], + level=3, + ncols=1, + caption=f"Evaluation catalog of {experiment.start_date}. " + f"Earthquakes are filtered above Mw" + f" {min(experiment.magnitudes)}.", + add_ext=True, + ) + + # Include results from Experiment + test = experiment.tests[0] + for model in experiment.models: + fig_path = experiment.registry.get_figure(timestr, f"{test.name}_{model.name}") + report.add_figure( + f"{test.name}: {model.name}", + fig_path, + level=3, + caption="Catalog-based N-test", + add_ext=True, + width=200, + ) + + report.save(experiment.registry.abs(experiment.registry.run_dir)) diff --git a/floatcsep/cmd/main.py b/floatcsep/cmd/main.py index efeac17..7e449c8 100644 --- a/floatcsep/cmd/main.py +++ b/floatcsep/cmd/main.py @@ -2,10 +2,10 @@ import logging from floatcsep import __version__ -from floatcsep.experiment import Experiment +from floatcsep.experiment import Experiment, ExperimentComparison from floatcsep.logger import setup_logger, set_console_log_level -from floatcsep.utils import ExperimentComparison from floatcsep.postprocess import plot_results, plot_forecasts, plot_catalogs, plot_custom +from floatcsep.report import generate_report, reproducibility_report setup_logger() log = logging.getLogger("floatLogger") @@ -34,7 +34,7 @@ def run(config, **kwargs): plot_results(experiment=exp) plot_custom(experiment=exp) - exp.generate_report() + generate_report(experiment=exp) exp.make_repr() log.info("Finalized") @@ -54,7 +54,7 @@ def plot(config, **kwargs): plot_results(experiment=exp) plot_custom(experiment=exp) - exp.generate_report() + generate_report(experiment=exp) log.debug("") @@ -76,6 +76,7 @@ def reproduce(config, **kwargs): comp = ExperimentComparison(original_exp, reproduced_exp) comp.compare_results() + reproducibility_report(exp_comparison=comp) log.info("Finalized") log.debug("") diff --git a/floatcsep/environments.py b/floatcsep/environments.py index 14cf095..fc7b506 100644 --- a/floatcsep/environments.py +++ b/floatcsep/environments.py @@ -169,8 +169,7 @@ def create_environment(self, force=False): ] ) log.info(f"\tSub-conda environment created: {self.env_name}") - - self.install_dependencies() + self.install_dependencies() def env_exists(self) -> bool: """ diff --git a/floatcsep/experiment.py b/floatcsep/experiment.py index b4b8efa..3b3187f 100644 --- a/floatcsep/experiment.py +++ b/floatcsep/experiment.py @@ -1,4 +1,6 @@ import datetime +import filecmp +import hashlib import logging import os import shutil @@ -7,8 +9,9 @@ import numpy import yaml +import scipy + -from floatcsep import report from floatcsep.evaluation import Evaluation from floatcsep.logger import add_fhandler from floatcsep.model import Model, TimeDependentModel @@ -559,13 +562,6 @@ def read_results(self, test: Evaluation, window: str) -> List: return test.read_results(window, self.models) - def generate_report(self) -> None: - """Creates a report summarizing the Experiment's results.""" - - log.info(f"Saving report into {self.registry.run_dir}") - - report.generate_report(self) - def make_repr(self): log.info("Creating reproducibility config file") @@ -700,3 +696,144 @@ def from_yml(cls, config_yml: str, repr_dir=None, **kwargs): kwargs.pop("logging") return cls(**_dict, **kwargs) + + +class ExperimentComparison: + + def __init__(self, original, reproduced, **kwargs): + """""" + self.original = original + self.reproduced = reproduced + + self.num_results = {} + self.file_comp = {} + + @staticmethod + def obs_diff(obs_orig, obs_repr): + + return numpy.abs( + numpy.divide((numpy.array(obs_orig) - numpy.array(obs_repr)), numpy.array(obs_orig)) + ) + + @staticmethod + def test_stat(test_orig, test_repr): + + if isinstance(test_orig[0], str): + if not isinstance(test_orig[1], str): + stats = numpy.array( + [0, numpy.divide((test_repr[1] - test_orig[1]), test_orig[1]), 0, 0] + ) + else: + stats = None + else: + stats_orig = numpy.array( + [numpy.mean(test_orig), numpy.std(test_orig), scipy.stats.skew(test_orig)] + ) + stats_repr = numpy.array( + [numpy.mean(test_repr), numpy.std(test_repr), scipy.stats.skew(test_repr)] + ) + + ks = scipy.stats.ks_2samp(test_orig, test_repr) + stats = [*numpy.divide(numpy.abs(stats_repr - stats_orig), stats_orig), ks.pvalue] + return stats + + def get_results(self): + + win_orig = timewindow2str(self.original.timewindows) + win_repr = timewindow2str(self.reproduced.timewindows) + + tests_orig = self.original.tests + tests_repr = self.reproduced.tests + + models_orig = [i.name for i in self.original.models] + models_repr = [i.name for i in self.reproduced.models] + + results = dict.fromkeys([i.name for i in tests_orig]) + + for test in tests_orig: + if test.type in ["consistency", "comparative"]: + results[test.name] = dict.fromkeys(win_orig) + for tw in win_orig: + results_orig = self.original.read_results(test, tw) + results_repr = self.reproduced.read_results(test, tw) + results[test.name][tw] = { + models_orig[i]: { + "observed_statistic": self.obs_diff( + results_orig[i].observed_statistic, + results_repr[i].observed_statistic, + ), + "test_statistic": self.test_stat( + results_orig[i].test_distribution, + results_repr[i].test_distribution, + ), + } + for i in range(len(models_orig)) + } + + else: + results_orig = self.original.read_results(test, win_orig[-1]) + results_repr = self.reproduced.read_results(test, win_orig[-1]) + results[test.name] = { + models_orig[i]: { + "observed_statistic": self.obs_diff( + results_orig[i].observed_statistic, + results_repr[i].observed_statistic, + ), + "test_statistic": self.test_stat( + results_orig[i].test_distribution, results_repr[i].test_distribution + ), + } + for i in range(len(models_orig)) + } + + return results + + @staticmethod + def get_hash(filename): + + with open(filename, "rb") as f: + bytes_file = f.read() + readable_hash = hashlib.sha256(bytes_file).hexdigest() + return readable_hash + + def get_filecomp(self): + + win_orig = timewindow2str(self.original.timewindows) + win_repr = timewindow2str(self.reproduced.timewindows) + + tests_orig = self.original.tests + tests_repr = self.reproduced.tests + + models_orig = [i.name for i in self.original.models] + models_repr = [i.name for i in self.reproduced.models] + + results = dict.fromkeys([i.name for i in tests_orig]) + + for test in tests_orig: + if test.type in ["consistency", "comparative"]: + results[test.name] = dict.fromkeys(win_orig) + for tw in win_orig: + results[test.name][tw] = dict.fromkeys(models_orig) + for model in models_orig: + orig_path = self.original.registry.get_result(tw, test, model) + repr_path = self.reproduced.registry.get_result(tw, test, model) + + results[test.name][tw][model] = { + "hash": (self.get_hash(orig_path) == self.get_hash(repr_path)), + "byte2byte": filecmp.cmp(orig_path, repr_path), + } + else: + results[test.name] = dict.fromkeys(models_orig) + for model in models_orig: + orig_path = self.original.registry.get_result(win_orig[-1], test, model) + repr_path = self.reproduced.registry.get_result(win_orig[-1], test, model) + results[test.name][model] = { + "hash": (self.get_hash(orig_path) == self.get_hash(repr_path)), + "byte2byte": filecmp.cmp(orig_path, repr_path), + } + return results + + def compare_results(self): + + self.num_results = self.get_results() + self.file_comp = self.get_filecomp() diff --git a/floatcsep/model.py b/floatcsep/model.py index 1d748a4..5a792c5 100644 --- a/floatcsep/model.py +++ b/floatcsep/model.py @@ -309,7 +309,7 @@ def __init__( self.registry, model_class=self.__class__.__name__, **kwargs ) self.build = kwargs.get("build", None) - + self.force_build = kwargs.get("force_build", False) if self.func: self.environment = EnvironmentFactory.get_env( self.build, self.name, self.registry.abs(model_path) @@ -329,7 +329,7 @@ def stage(self, timewindows=None) -> None: self.get_source(self.zenodo_id, self.giturl, branch=self.repo_hash) if hasattr(self, "environment"): - self.environment.create_environment() + self.environment.create_environment(force=self.force_build) self.registry.build_tree( timewindows=timewindows, diff --git a/floatcsep/postprocess.py b/floatcsep/postprocess.py index a07fbab..aeecb35 100644 --- a/floatcsep/postprocess.py +++ b/floatcsep/postprocess.py @@ -136,7 +136,7 @@ def plot_catalogs(experiment: "Experiment") -> None: def plot_custom(experiment: "Experiment"): - plot_config = parse_plot_config(experiment.postprocess.get("plot_custom", None)) + plot_config = parse_plot_config(experiment.postprocess.get("plot_custom", False)) if plot_config is None: return script_path, func_name = plot_config @@ -152,7 +152,7 @@ def plot_custom(experiment: "Experiment"): log.error(f"Script {script_path} is not in the configuration file directory.") log.info( "\t Skipping plotting. Script can be reallocated and re-run the plotting only" - " by typing 'floatcsep run {config}'" + " by typing 'floatcsep plot {config}'" ) return @@ -168,7 +168,7 @@ def plot_custom(experiment: "Experiment"): log.error(f"Function {func_name} not found in {script_path}") log.info( "\t Skipping plotting. Plot script can be modified and re-run the plotting only" - " by typing 'floatcsep run {config}'" + " by typing 'floatcsep plot {config}'" ) return @@ -178,7 +178,7 @@ def plot_custom(experiment: "Experiment"): log.error(f"Error executing {func_name} from {script_path}: {e}") log.info( "\t Skipping plotting. Plot script can be modified and re-run the plotting only" - " by typing 'floatcsep run {config}'" + " by typing 'floatcsep plot {config}'" ) return @@ -207,7 +207,7 @@ def parse_plot_config(plot_config: Union[dict, str, bool]): ) log.info( "\t Skipping plotting. The script can be modified and re-run the plotting only " - "by typing 'floatcsep run {config}'" + "by typing 'floatcsep plot {config}'" ) return diff --git a/floatcsep/report.py b/floatcsep/report.py index 51aa9a1..85ef608 100644 --- a/floatcsep/report.py +++ b/floatcsep/report.py @@ -1,4 +1,19 @@ -from floatcsep.utils import MarkdownReport, timewindow2str +import importlib.util +import itertools +import logging +import os +from typing import TYPE_CHECKING + +import numpy + +from floatcsep.experiment import ExperimentComparison +from floatcsep.utils import timewindow2str, str2timewindow + +if TYPE_CHECKING: + from floatcsep.experiment import Experiment + + +log = logging.getLogger("floatLogger") """ Use the MarkdownReport class to create output for the experiment. @@ -15,13 +30,18 @@ def generate_report(experiment, timewindow=-1): + report_function = experiment.postprocess.get("report") + if report_function: + custom_report(report_function, experiment) + return + timewindow = experiment.timewindows[timewindow] timestr = timewindow2str(timewindow) - hooks = experiment.report_hook - report = MarkdownReport() - report.add_title(f"Experiment Report - {experiment.name}", hooks.get("title_text", "")) + log.info(f"Saving report into {experiment.registry.run_dir}") + report = MarkdownReport() + report.add_title(f"Experiment Report - {experiment.name}", "") report.add_heading("Objectives", level=2) objs = [ @@ -30,10 +50,6 @@ def generate_report(experiment, timewindow=-1): f" M>{min(experiment.magnitudes)}.", ] - if hooks.get("objectives", None): - for i in hooks.get("objectives"): - objs.append(i) - report.add_list(objs) report.add_heading("Authoritative Data", level=2) @@ -54,16 +70,6 @@ def generate_report(experiment, timewindow=-1): f" {min(experiment.magnitudes)}.", add_ext=True, ) - - report.add_heading( - "Results", - level=2, - text="The following tests are applied to each of the experiment's " - "forecasts. More information regarding the tests can be found " - "[here]" - "(https://docs.cseptesting.org/getting_started/theory.html).", - ) - test_names = [test.name for test in experiment.tests] report.add_list(test_names) @@ -90,3 +96,372 @@ def generate_report(experiment, timewindow=-1): pass report.table_of_contents() report.save(experiment.registry.abs(experiment.registry.run_dir)) + + +def reproducibility_report(exp_comparison: "ExperimentComparison"): + + numerical = exp_comparison.num_results + data = exp_comparison.file_comp + outname = os.path.join("reproducibility_report.md") + save_path = os.path.dirname( + os.path.join( + exp_comparison.reproduced.registry.workdir, + exp_comparison.reproduced.registry.run_dir, + ) + ) + report = MarkdownReport(out_name=outname) + report.add_title(f"Reproducibility Report - {exp_comparison.original.name}", "") + + report.add_heading("Objectives", level=2) + objs = [ + "Analyze the statistic reproducibility and data reproducibility of" + " the experiment. Compares the differences between " + "(i) the original and reproduced scores," + " (ii) the statistical descriptors of the test distributions," + " (iii) The p-value of a Kolmogorov-Smirnov test -" + " values beneath 0.1 means we can't reject the distributions are" + " similar -," + " (iv) Hash (SHA-256) comparison between the results' files and " + "(v) byte-to-byte comparison" + ] + + report.add_list(objs) + for num, dat in zip(numerical.items(), data.items()): + + res_keys = list(num[1].keys()) + is_time = False + try: + str2timewindow(res_keys[0]) + is_time = True + except ValueError: + pass + if is_time: + report.add_heading(num[0], level=2) + for tw in res_keys: + rows = [ + [ + tw, + "Score difference", + "Test Mean diff.", + "Test Std diff.", + "Test Skew diff.", + "KS-test p value", + "Hash (SHA-256) equal", + "Byte-to-byte equal", + ] + ] + + for model_stat, model_file in zip(num[1][tw].items(), dat[1][tw].items()): + obs = model_stat[1]["observed_statistic"] + test = model_stat[1]["test_statistic"] + rows.append( + [ + model_stat[0], + obs, + *[f"{i:.1e}" for i in test[:-1]], + f"{test[-1]:.1e}", + model_file[1]["hash"], + model_file[1]["byte2byte"], + ] + ) + report.add_table(rows) + else: + report.add_heading(num[0], level=2) + rows = [ + [ + res_keys[-1], + "Max Score difference", + "Hash (SHA-256) equal", + "Byte-to-byte equal", + ] + ] + + for model_stat, model_file in zip(num[1].items(), dat[1].items()): + obs = numpy.nanmax(model_stat[1]["observed_statistic"]) + + rows.append( + [ + model_stat[0], + f"{obs:.1e}", + model_file[1]["hash"], + model_file[1]["byte2byte"], + ] + ) + + report.add_table(rows) + report.table_of_contents() + report.save(save_path) + + +def custom_report(report_function: str, experiment: "Experiment"): + + try: + script_path, func_name = report_function.split(".py:") + script_path += ".py" + + except ValueError: + log.error( + f"Invalid format for custom plot function: {report_function}. " + "Try {script_name}.py:{func}" + ) + log.info( + "\t Skipping reporting. The configuration script can be modified and re-run the" + " reporting (and plots) only by typing 'floatcsep plot {config}'" + ) + return + + log.info(f"Creating report from script {script_path} and function {func_name}") + script_abs_path = experiment.registry.abs(script_path) + allowed_directory = os.path.dirname(experiment.registry.abs(experiment.config_file)) + + if not os.path.isfile(script_path) or ( + os.path.dirname(script_abs_path) != os.path.realpath(allowed_directory) + ): + + log.error(f"Script {script_path} is not in the configuration file directory.") + log.info( + "\t Skipping reporting. The script can be reallocated and re-run the reporting only" + " by typing 'floatcsep plot {config}'" + ) + return + + module_name = os.path.splitext(os.path.basename(script_abs_path))[0] + spec = importlib.util.spec_from_file_location(module_name, script_abs_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + try: + func = getattr(module, func_name) + + except AttributeError: + log.error(f"Function {func_name} not found in {script_path}") + log.info( + "\t Skipping reporting. Report script can be modified and re-run the report only" + " by typing 'floatcsep plot {config}'" + ) + return + + try: + func(experiment) + except Exception as e: + log.error(f"Error executing {func_name} from {script_path}: {e}") + log.info( + "\t Skipping reporting. Report script can be modified and re-run the report only" + " by typing 'floatcsep plot {config}'" + ) + return + + +class MarkdownReport: + """Class to generate a Markdown report from a study.""" + + def __init__(self, out_name="report.md"): + self.out_name = out_name + self.toc = [] + self.has_title = True + self.has_introduction = False + self.markdown = [] + + def add_introduction(self, adict): + """Generate document header from dictionary.""" + first = ( + f"# CSEP Testing Results: {adict['simulation_name']} \n" + f"**Forecast Name:** {adict['forecast_name']} \n" + f"**Simulation Start Time:** {adict['origin_time']} \n" + f"**Evaluation Time:** {adict['evaluation_time']} \n" + f"**Catalog Source:** {adict['catalog_source']} \n" + f"**Number Simulations:** {adict['num_simulations']}\n" + ) + + # used to determine to place TOC at beginning of document or after + # introduction. + + self.has_introduction = True + self.markdown.append(first) + return first + + def add_text(self, text): + """ + Text should be a list of strings where each string will be on its own. + + line. Each add_text command represents a paragraph. + + Args: + text (list): lines to write + Returns: + """ + self.markdown.append(" ".join(text) + "\n\n") + + def add_figure( + self, + title, + relative_filepaths, + level=2, + ncols=1, + add_ext=False, + text="", + caption="", + width=None, + ): + """ + This function expects a list of filepaths. + + If you want the output + stacked, select a value of ncols. ncols should be divisible by + filepaths. + + Args: + width: + caption: + text: + add_ext: + ncols: + title: name of the figure + level (int): value 1-6 depending on the heading + relative_filepaths (str or List[Tuple[str]]): list of paths in + order to make table + Returns: + """ + # verify filepaths have proper extension should always be png + is_single = False + paths = [] + if isinstance(relative_filepaths, str): + is_single = True + paths.append(relative_filepaths) + else: + paths = relative_filepaths + + correct_paths = [] + if add_ext: + for fp in paths: + correct_paths.append(fp + ".png") + else: + correct_paths = paths + + # generate new lists with size ncols + formatted_paths = [correct_paths[i : i + ncols] for i in range(0, len(paths), ncols)] + + # convert str into a list, where each potential row is an iter not str + def build_header(_row): + top = "|" + bottom = "|" + for i, _ in enumerate(_row): + if i == ncols: + break + top += " |" + bottom += " --- |" + return top + "\n" + bottom + + size_ = bool(width) * f"width={width}" + + def add_to_row(_row): + if len(_row) == 1: + return f'' + string = "| " + for item in _row: + string = string + f'' + return string + + level_string = f"{level * '#'}" + result_cell = [] + locator = title.lower().replace(" ", "_") + result_cell.append(f'{level_string} {title} \n') + result_cell.append(f"{text}\n") + + for i, row in enumerate(formatted_paths): + if i == 0 and not is_single and ncols > 1: + result_cell.append(build_header(row)) + result_cell.append(add_to_row(row)) + result_cell.append("\n") + result_cell.append(f"{caption}") + + self.markdown.append("\n".join(result_cell) + "\n") + + # generate metadata for TOC + self.toc.append((title, level, locator)) + + def add_heading(self, title, level=1, text="", add_toc=True): + # multiplying char simply repeats it + if isinstance(text, str): + text = [text] + cell = [] + level_string = f"{level * '#'}" + locator = title.lower().replace(" ", "_") + sub_heading = f'{level_string} {title} \n' + cell.append(sub_heading) + try: + for item in list(text): + cell.append(item) + except Exception as ex: + raise RuntimeWarning(f"Unable to add document subhead, text must be iterable. {ex}") + self.markdown.append("\n".join(cell) + "\n") + + # generate metadata for TOC + if add_toc: + self.toc.append((title, level, locator)) + + def add_list(self, _list): + cell = [] + for item in _list: + cell.append(f"* {item}") + self.markdown.append("\n".join(cell) + "\n\n") + + def add_title(self, title, text): + self.has_title = True + self.add_heading(title, 1, text, add_toc=False) + + def table_of_contents(self): + """Generates table of contents based on contents of document.""" + if len(self.toc) == 0: + return + toc = ["# Table of Contents"] + + for i, elem in enumerate(self.toc): + title, level, locator = elem + space = " " * (level - 1) + toc.append(f"{space}1. [{title}](#{locator})") + insert_loc = 1 if self.has_title else 0 + self.markdown.insert(insert_loc, "\n".join(toc) + "\n\n") + + def add_table(self, data, use_header=True): + """ + Generates table from HTML and styles using bootstrap class. + + Args: + data List[Tuple[str]]: should be (nrows, ncols) in size. all rows + should be the same sizes + Returns: + table (str): this can be added to subheading or other cell if + desired. + """ + table = ['
', f""] + + def make_header(row_): + header = [""] + for item in row_: + header.append(f"") + header.append("") + return "\n".join(header) + + def add_row(row_): + table_row = [""] + for item in row_: + table_row.append(f"") + table_row.append("") + return "\n".join(table_row) + + for i, row in enumerate(data): + if i == 0 and use_header: + table.append(make_header(row)) + else: + table.append(add_row(row)) + table.append("
{item}
{item}
") + table.append("
") + table = "\n".join(table) + self.markdown.append(table + "\n\n") + + def save(self, save_dir): + output = list(itertools.chain.from_iterable(self.markdown)) + full_md_fname = os.path.join(save_dir, self.out_name) + with open(full_md_fname, "w") as f: + f.writelines(output) diff --git a/floatcsep/utils.py b/floatcsep/utils.py index 27e9f79..a540c9e 100644 --- a/floatcsep/utils.py +++ b/floatcsep/utils.py @@ -1,8 +1,6 @@ # python libraries import copy -import filecmp import functools -import hashlib import itertools import logging import multiprocessing @@ -23,7 +21,6 @@ import shapely.geometry # pyCSEP libraries -import six import yaml from csep.core.forecasts import GriddedForecast from csep.core.regions import CartesianGrid2D, compute_vertices @@ -587,455 +584,12 @@ def check_exist(self): pass -class MarkdownReport: - """Class to generate a Markdown report from a study.""" - - def __init__(self, outname="report.md"): - self.outname = outname - self.toc = [] - self.has_title = True - self.has_introduction = False - self.markdown = [] - - def add_introduction(self, adict): - """Generate document header from dictionary.""" - first = ( - f"# CSEP Testing Results: {adict['simulation_name']} \n" - f"**Forecast Name:** {adict['forecast_name']} \n" - f"**Simulation Start Time:** {adict['origin_time']} \n" - f"**Evaluation Time:** {adict['evaluation_time']} \n" - f"**Catalog Source:** {adict['catalog_source']} \n" - f"**Number Simulations:** {adict['num_simulations']}\n" - ) - - # used to determine to place TOC at beginning of document or after - # introduction. - - self.has_introduction = True - self.markdown.append(first) - return first - - def add_text(self, text): - """ - Text should be a list of strings where each string will be on its own. - - line. Each add_text command represents a paragraph. - - Args: - text (list): lines to write - Returns: - """ - self.markdown.append(" ".join(text) + "\n\n") - - def add_figure( - self, - title, - relative_filepaths, - level=2, - ncols=1, - add_ext=False, - text="", - caption="", - width=None, - ): - """ - This function expects a list of filepaths. - - If you want the output - stacked, select a value of ncols. ncols should be divisible by - filepaths. todo: modify formatted_paths to work when not divis. - - Args: - width: - caption: - text: - add_ext: - ncols: - title: name of the figure - level (int): value 1-6 depending on the heading - relative_filepaths (str or List[Tuple[str]]): list of paths in - order to make table - Returns: - """ - # verify filepaths have proper extension should always be png - is_single = False - paths = [] - if isinstance(relative_filepaths, six.string_types): - is_single = True - paths.append(relative_filepaths) - else: - paths = relative_filepaths - - correct_paths = [] - if add_ext: - for fp in paths: - correct_paths.append(fp + ".png") - else: - correct_paths = paths - - # generate new lists with size ncols - formatted_paths = [correct_paths[i : i + ncols] for i in range(0, len(paths), ncols)] - - # convert str into a list, where each potential row is an iter not str - def build_header(_row): - top = "|" - bottom = "|" - for i, _ in enumerate(_row): - if i == ncols: - break - top += " |" - bottom += " --- |" - return top + "\n" + bottom - - size_ = bool(width) * f"width={width}" - - def add_to_row(_row): - if len(_row) == 1: - return f'' - string = "| " - for item in _row: - string = string + f'' - return string - - level_string = f"{level * '#'}" - result_cell = [] - locator = title.lower().replace(" ", "_") - result_cell.append(f'{level_string} {title} \n') - result_cell.append(f"{text}\n") - - for i, row in enumerate(formatted_paths): - if i == 0 and not is_single and ncols > 1: - result_cell.append(build_header(row)) - result_cell.append(add_to_row(row)) - result_cell.append("\n") - result_cell.append(f"{caption}") - - self.markdown.append("\n".join(result_cell) + "\n") - - # generate metadata for TOC - self.toc.append((title, level, locator)) - - def add_heading(self, title, level=1, text="", add_toc=True): - # multiplying char simply repeats it - if isinstance(text, str): - text = [text] - cell = [] - level_string = f"{level * '#'}" - locator = title.lower().replace(" ", "_") - sub_heading = f'{level_string} {title} \n' - cell.append(sub_heading) - try: - for item in list(text): - cell.append(item) - except Exception as ex: - raise RuntimeWarning("Unable to add document subhead, text must be iterable.") - self.markdown.append("\n".join(cell) + "\n") - - # generate metadata for TOC - if add_toc: - self.toc.append((title, level, locator)) - - def add_list(self, _list): - cell = [] - for item in _list: - cell.append(f"* {item}") - self.markdown.append("\n".join(cell) + "\n\n") - - def add_title(self, title, text): - self.has_title = True - self.add_heading(title, 1, text, add_toc=False) - - def table_of_contents(self): - """Generates table of contents based on contents of document.""" - if len(self.toc) == 0: - return - toc = ["# Table of Contents"] - - for i, elem in enumerate(self.toc): - title, level, locator = elem - space = " " * (level - 1) - toc.append(f"{space}1. [{title}](#{locator})") - insert_loc = 1 if self.has_title else 0 - self.markdown.insert(insert_loc, "\n".join(toc) + "\n\n") - - def add_table(self, data, use_header=True): - """ - Generates table from HTML and styles using bootstrap class. - - Args: - data List[Tuple[str]]: should be (nrows, ncols) in size. all rows - should be the same sizes - Returns: - table (str): this can be added to subheading or other cell if - desired. - """ - table = ['
', f""] - - def make_header(row): - header = [""] - for item in row: - header.append(f"") - header.append("") - return "\n".join(header) - - def add_row(row): - table_row = [""] - for item in row: - table_row.append(f"") - table_row.append("") - return "\n".join(table_row) - - for i, row in enumerate(data): - if i == 0 and use_header: - table.append(make_header(row)) - else: - table.append(add_row(row)) - table.append("
{item}
{item}
") - table.append("
") - table = "\n".join(table) - self.markdown.append(table + "\n\n") - - def save(self, save_dir): - output = list(itertools.chain.from_iterable(self.markdown)) - full_md_fname = os.path.join(save_dir, self.outname) - with open(full_md_fname, "w") as f: - f.writelines(output) - - class NoAliasLoader(yaml.Loader): @staticmethod def ignore_aliases(self): return True -class ExperimentComparison: - - def __init__(self, original, reproduced, **kwargs): - """""" - self.original = original - self.reproduced = reproduced - - self.num_results = {} - self.file_comp = {} - - @staticmethod - def obs_diff(obs_orig, obs_repr): - - return numpy.abs( - numpy.divide((numpy.array(obs_orig) - numpy.array(obs_repr)), numpy.array(obs_orig)) - ) - - @staticmethod - def test_stat(test_orig, test_repr): - - if isinstance(test_orig[0], str): - if not isinstance(test_orig[1], str): - stats = numpy.array( - [0, numpy.divide((test_repr[1] - test_orig[1]), test_orig[1]), 0, 0] - ) - else: - stats = None - else: - stats_orig = numpy.array( - [numpy.mean(test_orig), numpy.std(test_orig), scipy.stats.skew(test_orig)] - ) - stats_repr = numpy.array( - [numpy.mean(test_repr), numpy.std(test_repr), scipy.stats.skew(test_repr)] - ) - - ks = scipy.stats.ks_2samp(test_orig, test_repr) - stats = [*numpy.divide(numpy.abs(stats_repr - stats_orig), stats_orig), ks.pvalue] - return stats - - def get_results(self): - - win_orig = timewindow2str(self.original.timewindows) - win_repr = timewindow2str(self.reproduced.timewindows) - - tests_orig = self.original.tests - tests_repr = self.reproduced.tests - - models_orig = [i.name for i in self.original.models] - models_repr = [i.name for i in self.reproduced.models] - - results = dict.fromkeys([i.name for i in tests_orig]) - - for test in tests_orig: - if test.type in ["consistency", "comparative"]: - results[test.name] = dict.fromkeys(win_orig) - for tw in win_orig: - results_orig = self.original.read_results(test, tw) - results_repr = self.reproduced.read_results(test, tw) - results[test.name][tw] = { - models_orig[i]: { - "observed_statistic": self.obs_diff( - results_orig[i].observed_statistic, - results_repr[i].observed_statistic, - ), - "test_statistic": self.test_stat( - results_orig[i].test_distribution, - results_repr[i].test_distribution, - ), - } - for i in range(len(models_orig)) - } - - else: - results_orig = self.original.read_results(test, win_orig[-1]) - results_repr = self.reproduced.read_results(test, win_orig[-1]) - results[test.name] = { - models_orig[i]: { - "observed_statistic": self.obs_diff( - results_orig[i].observed_statistic, - results_repr[i].observed_statistic, - ), - "test_statistic": self.test_stat( - results_orig[i].test_distribution, results_repr[i].test_distribution - ), - } - for i in range(len(models_orig)) - } - - return results - - @staticmethod - def get_hash(filename): - - with open(filename, "rb") as f: - bytes_file = f.read() - readable_hash = hashlib.sha256(bytes_file).hexdigest() - return readable_hash - - def get_filecomp(self): - - win_orig = timewindow2str(self.original.timewindows) - win_repr = timewindow2str(self.reproduced.timewindows) - - tests_orig = self.original.tests - tests_repr = self.reproduced.tests - - models_orig = [i.name for i in self.original.models] - models_repr = [i.name for i in self.reproduced.models] - - results = dict.fromkeys([i.name for i in tests_orig]) - - for test in tests_orig: - if test.type in ["consistency", "comparative"]: - results[test.name] = dict.fromkeys(win_orig) - for tw in win_orig: - results[test.name][tw] = dict.fromkeys(models_orig) - for model in models_orig: - orig_path = self.original.registry.get_result(tw, test, model) - repr_path = self.reproduced.registry.get_result(tw, test, model) - - results[test.name][tw][model] = { - "hash": (self.get_hash(orig_path) == self.get_hash(repr_path)), - "byte2byte": filecmp.cmp(orig_path, repr_path), - } - else: - results[test.name] = dict.fromkeys(models_orig) - for model in models_orig: - orig_path = self.original.registry.get_result(win_orig[-1], test, model) - repr_path = self.reproduced.registry.get_result(win_orig[-1], test, model) - results[test.name][model] = { - "hash": (self.get_hash(orig_path) == self.get_hash(repr_path)), - "byte2byte": filecmp.cmp(orig_path, repr_path), - } - return results - - def compare_results(self): - - self.num_results = self.get_results() - self.file_comp = self.get_filecomp() - self.write_report() - - def write_report(self): - - numerical = self.num_results - data = self.file_comp - outname = os.path.join("reproducibility_report.md") - save_path = os.path.dirname( - os.path.join(self.reproduced.registry.workdir, self.reproduced.registry.run_dir) - ) - report = MarkdownReport(outname=outname) - report.add_title(f"Reproducibility Report - {self.original.name}", "") - - report.add_heading("Objectives", level=2) - objs = [ - "Analyze the statistic reproducibility and data reproducibility of" - " the experiment. Compares the differences between " - "(i) the original and reproduced scores," - " (ii) the statistical descriptors of the test distributions," - " (iii) The p-value of a Kolmogorov-Smirnov test -" - " values beneath 0.1 means we can't reject the distributions are" - " similar -," - " (iv) Hash (SHA-256) comparison between the results' files and " - "(v) byte-to-byte comparison" - ] - - report.add_list(objs) - for num, dat in zip(numerical.items(), data.items()): - - res_keys = list(num[1].keys()) - is_time = False - try: - str2timewindow(res_keys[0]) - is_time = True - except ValueError: - pass - if is_time: - report.add_heading(num[0], level=2) - for tw in res_keys: - rows = [ - [ - tw, - "Score difference", - "Test Mean diff.", - "Test Std diff.", - "Test Skew diff.", - "KS-test p value", - "Hash (SHA-256) equal", - "Byte-to-byte equal", - ] - ] - - for model_stat, model_file in zip(num[1][tw].items(), dat[1][tw].items()): - obs = model_stat[1]["observed_statistic"] - test = model_stat[1]["test_statistic"] - rows.append( - [ - model_stat[0], - obs, - *[f"{i:.1e}" for i in test[:-1]], - f"{test[-1]:.1e}", - model_file[1]["hash"], - model_file[1]["byte2byte"], - ] - ) - report.add_table(rows) - else: - report.add_heading(num[0], level=2) - rows = [ - [tw, "Max Score difference", "Hash (SHA-256) equal", "Byte-to-byte equal"] - ] - - for model_stat, model_file in zip(num[1].items(), dat[1].items()): - obs = numpy.nanmax(model_stat[1]["observed_statistic"]) - - rows.append( - [ - model_stat[0], - f"{obs:.1e}", - model_file[1]["hash"], - model_file[1]["byte2byte"], - ] - ) - - report.add_table(rows) - report.table_of_contents() - report.save(save_path) - - ####################### # Perhaps add to pycsep ####################### diff --git a/requirements.txt b/requirements.txt index 81d20f8..eefaee8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ pycsep pyshp pyyaml requests +scipy seaborn tables xmltodict \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index fb356c6..ee8bd06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,7 @@ install_requires = pyshp pyyaml requests + scipy seaborn tables xmltodict diff --git a/tests/qa/test_data.py b/tests/qa/test_data.py index 872b4be..541f3e2 100644 --- a/tests/qa/test_data.py +++ b/tests/qa/test_data.py @@ -34,10 +34,10 @@ def get_eval_dist(self): pass -@patch.object(Experiment, "generate_report") @patch("floatcsep.cmd.main.plot_forecasts") @patch("floatcsep.cmd.main.plot_catalogs") @patch("floatcsep.cmd.main.plot_custom") +@patch("floatcsep.cmd.main.generate_report") class RunExamples(DataTest): def test_case_a(self, *args): @@ -79,6 +79,7 @@ def test_case_g(self, *args): @patch("floatcsep.cmd.main.plot_forecasts") @patch("floatcsep.cmd.main.plot_catalogs") @patch("floatcsep.cmd.main.plot_custom") +@patch("floatcsep.cmd.main.generate_report") class ReproduceExamples(DataTest): def test_case_c(self, *args): diff --git a/tests/unit/test_environments.py b/tests/unit/test_environments.py index dfe90f6..29cd4ba 100644 --- a/tests/unit/test_environments.py +++ b/tests/unit/test_environments.py @@ -96,9 +96,10 @@ def test_create_environment(self, mock_exists, mock_run): @patch("subprocess.run") def test_create_environment_force(self, mock_run): manager = CondaManager("test_base", "/path/to/model") - manager.env_exists = MagicMock(return_value=True) + manager.env_exists = MagicMock() + manager.env_exists.side_effect = [True, False] manager.create_environment(force=True) - self.assertEqual(mock_run.call_count, 2) # One for remove, one for create + self.assertEqual(mock_run.call_count, 3) # One for remove, one for create @patch("subprocess.run") @patch.object(CondaManager, "detect_package_manager", return_value="conda")