diff --git a/MAINTAINERS.md b/MAINTAINERS.md index a57e4bfbbbff4526527819a3f2e4840c58a9b799..f57f0702dacf98e84965cc10569c3748dca2fc31 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -23,6 +23,9 @@ Hadron models: - Felix Riehn <friehn@lip.pt>, Santiago/Lisbon - Anatoli Fedynitch <anatoli.fedynitch@icecube.wisc.edu> ICRR Tokyo +Python library: +- Remy Prechelt <prechelt@hawaii.edu>, UHM + Radio: - Remy Prechelt <prechelt@hawaii.edu> - Tim Huege <tim.huege@kit.edu>, KIT diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7b9e44cc96089e3d033d74eb46965d968bd28d52 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,147 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# ignore any generated output files +*.npz +*.dat + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# static files generated from Django application using `collectstatic` +media +static diff --git a/python/Makefile b/python/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..ccc13257fe572b456857657929c41d8199ad6529 --- /dev/null +++ b/python/Makefile @@ -0,0 +1,30 @@ +## +# ## +# corsika +# +# @file + +# find python3 +PYTHON=`which python3` + +# our testing targets +.PHONY: tests flake black isort all + +all: mypy isort black flake tests + +tests: + ${PYTHON} -m pytest --cov=corsika tests + +flake: + ${PYTHON} -m flake8 corsika tests + +black: + ${PYTHON} -m black -t py37 corsika tests + +isort: + ${PYTHON} -m isort --atomic corsika tests + +mypy: + ${PYTHON} -m mypy corsika + +# end diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7e35dfe389dde4d372b1e38301c41c42a45c6fa8 --- /dev/null +++ b/python/README.md @@ -0,0 +1,8 @@ +# CORSIKA 8 - Python Library + +To install this into your global environment using `pip` (not recommended), run + +``` shell +pip install --user git+https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika/-/tree/master/python +``` + diff --git a/python/corsika/__init__.py b/python/corsika/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..078d688098095f06e2a14d2db4c203370993d578 --- /dev/null +++ b/python/corsika/__init__.py @@ -0,0 +1,17 @@ +""" + A Python interface to CORSIKA 8. + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" + +from . import io +from .io.library import Library + +# all imported objects +__all__ = ["io", "Library"] + +__version__: str = "8.0.0-alpha" diff --git a/python/corsika/io/__init__.py b/python/corsika/io/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..296572d6dee46a4b95a61b8ef18c549f120a1205 --- /dev/null +++ b/python/corsika/io/__init__.py @@ -0,0 +1,15 @@ +""" + The 'io' module provides for reading CORSIKA8 output files. + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" + +from .hist import read_hist +from .library import Library + +# all exported objects +__all__ = ["read_hist", "Library"] diff --git a/python/corsika/io/hist.py b/python/corsika/io/hist.py new file mode 100644 index 0000000000000000000000000000000000000000..615db0faec0ec63cff8fe00fc5ebe1d8ce08f2ff --- /dev/null +++ b/python/corsika/io/hist.py @@ -0,0 +1,70 @@ +""" + This file supports reading boost_histograms from CORSIKA8. + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" + +import boost_histogram as bh +import numpy as np + + +def read_hist(filename: str) -> bh.Histogram: + """ + Read a histogram produced with CORSIKA8's `SaveBoostHistogram()` function. + + Parameters + ---------- + filename: str + The filename of the .npy file containing the histogram. + + Returns + ------- + hist: bh.Histogram + An initialized bh.Histogram instance. + + Throws + ------ + ValueError: + If the histogram type is not supported. + """ + + # load the filenames + d = np.load(filename) + + # extract the axis and overflows information + axistypes = d["axistypes"].view("c") + overflow = d["overflow"] + underflow = d["underflow"] + + # this is where we store the axes that we extract from the file. + axes = [] + + # we now loop over the axes + for i, (at, has_overflow, has_underflow) in enumerate( + zip(axistypes, overflow, underflow) + ): + + # continuous histogram + if at == b"c": + axes.append( + bh.axis.Variable( + d[f"binedges_{i}"], overflow=has_overflow, underflow=has_underflow + ) + ) + # discrete histogram + elif at == b"d": + axes.append(bh.axis.IntCategory(d[f"bins_{i}"], growth=(not has_overflow))) + + # and unknown histogram type + else: + raise ValueError(f"'{at}' is not a valid C8 histogram axistype.") + + # create the histogram and fill it in + h = bh.Histogram(*axes) + h.view(flow=True)[:] = d["data"] + + return h diff --git a/python/corsika/io/library.py b/python/corsika/io/library.py new file mode 100644 index 0000000000000000000000000000000000000000..4f0bb685ca4fea9f8e8a0508dcecbceb4f041ecc --- /dev/null +++ b/python/corsika/io/library.py @@ -0,0 +1,196 @@ +""" + This file allows for reading/working with C8 libraries. + + (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" +import logging +import os +import os.path as op +import re +from typing import Any, Dict, Optional + +import yaml + +from . import outputs + + +class Library(object): + """ + Represents a library ("run") of showers produced by C8. + """ + + def __init__(self, path: str): + """ + + Parameters + ---------- + path: str + The path to the directory containing the library. + + + Raises + ------ + ValueError + If `path` does not contain a valid CORSIKA8 library. + """ + + # check that this is a valid library + if not self.__valid_library(path): + raise ValueError(f"'{path}' does not contain a valid CORSIKA8 library.") + + # store the top-level path + self.path = path + + # load the config file + self.config = self.load_config(path) + + # build the list of outputs + self.__outputs = self.__build_outputs(path) + + def get(self, name: str) -> Optional[outputs.Output]: + """ + Return the output with a given name. + """ + if name in self.__outputs: + return self.__outputs[name] + else: + msg = f"Output with name '{name}' not available in this library." + logging.getLogger("corsika").warn(msg) + return None + + @staticmethod + def load_config(path: str) -> Dict[str, Any]: + """ + Load the top-level config from a given library path. + + + Parameters + ---------- + path: str + The path to the directory containing the library. + + Returns + ------- + dict: + The config as a python dictionary. + + Raises + ------ + FileNotFoundError + If the config file cannot be found + + """ + with open(op.join(path, "config.yaml"), "r") as f: + return yaml.load(f, Loader=yaml.Loader) + + @staticmethod + def __valid_library(path: str) -> bool: + """ + Check if the library pointed to by 'path' is a valid C8 library. + + Parameters + ---------- + path: str + The path to the directory containing the library. + + Returns + ------- + bool: + True if this is a valid C8 library. + + """ + + # check that the config file exists + if not op.exists(op.join(path, "config.yaml")): + return False + + # the config file exists, we load it + config = Library.load_config(path) + + # and check that the config's "writer" key is correct + return config["creator"] == "CORSIKA8" + + @staticmethod + def __build_outputs(path: str) -> Dict[str, outputs.Output]: + """ + Build the outputs contained in this library. + + This will print a warning message if a particular + output is invalid but will continue to load additional + outputs afterwards. + + Parameters + ---------- + path: str + The path to the directory containing this library. + + Returns + ------- + Dict[str, Output]: + A dictionary mapping names to initialized outputs. + """ + + # get a list of the subdirectories in the library + _, dirs, _ = next(os.walk(path)) + + # this is the dictionary where we store our components + outputs: Dict[str, Any] = {} + + # loop over the subdirectories + for subdir in dirs: + + # read the config file for this output + config = Library.load_config(op.join(path, subdir)) + + # the name keyword is our unique identifier + name = config.get("name") + + # get the "type" keyword to identify this component + out_type = config.get("type") + + # if `out_type` was None, this is an invalid output + if out_type is None or name is None: + msg = ( + f"'{subdir}' does not contain a valid config." + "Missing 'type' or 'name' keyword." + ) + logging.getLogger("corsika").warn(msg) + continue # skip to the next output, don't error + + # we now have a valid component type, get the corresponding + # type from the proccesses subdirectory + try: + + # create the name of the module containing this output class + module_name = re.sub(r"(?<!^)(?=[A-Z])", "_", out_type).lower() + + # instantiate the output and store it in our dict + # we use a regex to go from CamelCase to snake_case + component = getattr(getattr(outputs, module_name), out_type)( + op.join(path, subdir) + ) + + # check if the read failed + if not component.is_good(): + msg = ( + f"'{name}' encountered an error while reading. " + "This process will be not be loaded." + ) + logging.getLogger("corsika").warn(msg) + else: + outputs[name] = component + + except AttributeError: + msg = ( + f"Unable to instantiate an instance of '{out_type}' " + "for a process called '{name}'" + ) + logging.getLogger("corsika").warn(msg) + continue # skip to the next output, don't error + + # and we are done building - return the constructed outputs + return outputs diff --git a/python/corsika/io/outputs/__init__.py b/python/corsika/io/outputs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..67fb35a7bb269697d246e3db2db33aeb0d2c78ac --- /dev/null +++ b/python/corsika/io/outputs/__init__.py @@ -0,0 +1,13 @@ +""" + + (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" + +from .observation_plane import ObservationPlane +from .output import Output + +__all__ = ["Output", "ObservationPlane"] diff --git a/python/corsika/io/outputs/observation_plane.py b/python/corsika/io/outputs/observation_plane.py new file mode 100644 index 0000000000000000000000000000000000000000..00ac90dd43b0daf6c27f379da1d1eccb609e7a11 --- /dev/null +++ b/python/corsika/io/outputs/observation_plane.py @@ -0,0 +1,99 @@ +""" + Read data written by ObservationPlane. + + (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" +import logging +import os.path as op +from typing import Any, Dict + +import pyarrow.parquet as pq + +from .output import Output + + +class ObservationPlane(Output): + """ + Read particle data from an ObservationPlane. + """ + + def __init__(self, path: str): + """ + Load the particle data into a parquet table. + + Parameters + ---------- + path: str + The path to the directory containing this output. + """ + + # load and store our path and config + self.path = path + self.__config = self.load_config(path) + + # try and load our data + try: + self.__data = pq.read_table(op.join(path, "particles.parquet")) + except Exception as e: + logging.getLogger("corsika").warn( + f"An error occured loading an ObservationPlane: {e}" + ) + + def is_good(self) -> bool: + """ + Returns true if this output has been read successfully + and has the correct files/state/etc. + + Returns + ------- + bool: + True if this is a good output. + """ + return self.__data is not None and self.__config is not None + + def astype(self, dtype: str = "parquet", **kwargs: Any) -> Any: + """ + Load the particle data from this observation plane. + + All additional keyword arguments are passed to `parquet.read_table` + + Parameters + ---------- + dtype: str + The data format to return the data in (i.e. numpy, pandas, etc.) + + Returns + ------- + Any: + The return type of this method is determined by `dtype`. + """ + if dtype == "parquet": + return self.__data + elif dtype == "pandas": + return self.__data.to_pandas() + else: + raise ValueError( + ( + f"Unknown format '{dtype}' for ObservationPlane. " + "We currently only support ['parquet', 'pandas']." + ) + ) + + @property + def config(self) -> Dict[str, Any]: + """ + Return the config file for this output. + + Parameters + ---------- + + Returns + ------- + Dict[str, any] + The configuration file for this output. + """ + return self.__config diff --git a/python/corsika/io/outputs/output.py b/python/corsika/io/outputs/output.py new file mode 100644 index 0000000000000000000000000000000000000000..7d7e27e82085b2017ab624425df487ac6734686b --- /dev/null +++ b/python/corsika/io/outputs/output.py @@ -0,0 +1,108 @@ +""" + This file defines the API for all output readers. + + (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" +import os.path as op +from abc import ABC, abstractmethod +from typing import Any, Dict + +import yaml + + +class Output(ABC): + """ + This class defines the abstract interface for all classes + that wish to provide reading support for CORSIKA8 outputs. + """ + + @abstractmethod + def __init__(self, path: str): + """ + __init__ must load the output files and check + that it is valid. + + Parameters + ---------- + path: str + The path to the directory containing this output. + """ + pass + + @abstractmethod + def is_good(self) -> bool: + """ + Returns true if this output has been read successfully + and has the correct files/state/etc. + + Returns + ------- + bool: + True if this is a good output. + """ + pass + + @abstractmethod + def astype(self, dtype: str, **kwargs: Any) -> Any: + """ + Return the data for this output in the data format given by 'dtype' + + Parameters + ---------- + dtype: str + The data format to return the data in (i.e. numpy, pandas, etc.) + *args: Any + Additional arguments can be accepted by the output. + **kwargs: Any + Additional keyword arguments can be accepted by the output. + + Returns + ------- + Any: + The return type of this method is determined by `dtype`. + """ + pass + + @property + @abstractmethod + def config(self) -> Dict[str, Any]: + """ + Return the config file for this output. + + Parameters + ---------- + + Returns + ------- + Dict[str, any] + The configuration file for this output. + """ + pass + + @staticmethod + def load_config(path: str) -> Dict[str, Any]: + """ + Load the top-level config from a given library path. + + Parameters + ---------- + path: str + The path to the directory containing the library. + + Returns + ------- + dict: + The config as a python dictionary. + + Raises + ------ + FileNotFoundError + If the config file cannot be found + + """ + with open(op.join(path, "config.yaml"), "r") as f: + return yaml.load(f, Loader=yaml.Loader) diff --git a/python/setup.cfg b/python/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..f58d04eeb061f000b2d890aa26f542f70f3aa0c0 --- /dev/null +++ b/python/setup.cfg @@ -0,0 +1,69 @@ +[flake8] +# use a slightly longer line to be consistent with black +max-line-length = 88 + +# E231 is missing whitespace after a comma +# this contradicts the black formatting rules +# and therefore creates spurious errors +ignore = E231 + +# we set various directories we want to exclude +exclude = + # Don't bother checking in cache directories + __pycache__ + + +[isort] +# use parenthesis for multi-line imports +use_parentheses = true + + +[mypy] +# the primary Python version +python_version = 3.7 + +# allow returning Any +# this creates excessive errors when using libraries +# that don't have MyPy typing support +warn_return_any = False + +# don't allow untyped functions +disallow_untyped_defs = True + +# warn if any part of this config is mispelled +warn_unused_configs = True + +# warn for missing type information +warn_incomplete_stub = True + +# warn us if we don't return from a function explicitly +warn_no_return = True + +# use incremental typing to speed things up +incremental = True + +# show error contexts +show_error_context = True + +# and show the column numbers for errors +show_column_numbers = True + +# ignore missing types for setuptools +[mypy-setuptools.*] +ignore_missing_imports = True + +# ignore missing types for numpy +[mypy-numpy.*] +ignore_missing_imports = True + +# ignore missing types for matplotlib +[mypy-matplotlib.*] +ignore_missing_imports = True + +# ignore missing types for boost_histogram +[mypy-boost_histogram.*] +ignore_missing_imports = True + +# ignore missing types for pyarow +[mypy-pyarrow.*] +ignore_missing_imports = True diff --git a/python/setup.py b/python/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..196c2ecbbccbc7e5caafee93be0af1c41746c94f --- /dev/null +++ b/python/setup.py @@ -0,0 +1,51 @@ +from os import path +from setuptools import setup + +# the stereo version +__version__ = "8.0.0-alpha" + +# get the absolute path of this project +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +# the standard setup info +setup( + name="corsika", + version=__version__, + description="A Python package for working with CORSIKA 8.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika", + author="CORSIKA 8 Collaboration", + author_email="corsika-devel@lists.kit.edu", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Physics", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ], + keywords=["cosmic ray", "physics", "air shower", "simulation"], + packages=["corsika"], + python_requires=">=3.6*, <4", + install_requires=["numpy", "pyyaml", "pyarrow", "boost_histogram"], + extras_require={ + "test": [ + "pytest", + "black", + "mypy", + "isort", + "coverage", + "pytest-cov", + "flake8", + ], + "pandas": ["pandas"], + }, + scripts=[], + project_urls={"code": "https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika"}, + include_package_data=False, +) diff --git a/python/tests/__init__.py b/python/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c7d9b672b4c1bde7cc7725c619c0390a79e682c7 --- /dev/null +++ b/python/tests/__init__.py @@ -0,0 +1,56 @@ +""" + Tests for `corsika` + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" + +import os +import os.path as op + +__all__ = ["build_directory"] + + +def find_build_directory() -> str: + """ + Return the absolute path to the CORSIKA8 build directory. + + Returns + ------- + str + The absolute path to the build directory. + + Raises + ------ + RuntimeError + If the build directory cannot be found or it is empty. + """ + + # check if we are running on Gitlab + gitlab_build_dir = os.getenv("CI_BUILDS_DIR") + + # if we are running on Gitlab + if gitlab_build_dir is not None: + build_dir = op.abspath(gitlab_build_dir) + else: # otherwise, we are running locally + build_dir = op.abspath( + op.join( + op.dirname(__file__), + op.pardir, + op.pardir, + "build", + ) + ) + + # check that the build directory contains 'CMakeCache.txt' + if not op.exists(op.join(build_dir, "CMakeCache.txt")): + raise RuntimeError("Python tests cannot find C8 build directory.") + + return build_dir + + +# find the build_directory once +build_directory = find_build_directory() diff --git a/python/tests/io/__init__.py b/python/tests/io/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0d08be944644953fd08405a4502456e76c1203b9 --- /dev/null +++ b/python/tests/io/__init__.py @@ -0,0 +1,9 @@ +""" + Tests for `corsika.io` + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" diff --git a/python/tests/io/test_hist.py b/python/tests/io/test_hist.py new file mode 100644 index 0000000000000000000000000000000000000000..7cf5b38fb7798c81680cf14d108e3b31dd3dcaf1 --- /dev/null +++ b/python/tests/io/test_hist.py @@ -0,0 +1,70 @@ +""" + Tests for `corsika.io.hist` + + (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu + + This software is distributed under the terms of the GNU General Public + Licence version 3 (GPL Version 3). See file LICENSE for a full version of + the license. +""" +import os +import os.path as op +import subprocess + +import corsika + +from .. import build_directory + +# the directory containing 'testSaveBoostHistogram' +bindir = op.join(build_directory, "Framework", "Utilities") + + +def generate_hist() -> str: + """ + Generate a test with `testSaveBoostHistogram`. + + Returns + ------- + str + The path to the generated histogram. + bool + If True, this file was regenerated. + """ + + # we construct the name of the bin + bin = op.join(bindir, "testSaveBoostHistogram") + + # check if a histogram already exists + if op.exists(op.join(bin, "hist.npz")): + return op.join(bin, "hist.npz"), False + else: + # run the program - this generates "hist.npz" in the CWD + subprocess.call(bin) + + return op.join(os.getcwd(), "hist.npz"), True + + +def test_corsika_io() -> None: + """ + Test I can corsika.io without a further import. + """ + corsika.io.read_hist + + +def test_corsika_read_hist() -> None: + """ + Check that I can read in the test histograms with `read_hist`. + """ + + # generate a test histogram + filename, delete = generate_hist() + + # and try and read in a histogram + h = corsika.io.read_hist(filename) + + # and delete the generated histogram + if delete: + os.remove(filename) + + # and check that it isn't empty + assert not h.empty()