Skip to content

Commit

Permalink
Merge pull request #14 from jacanchaplais/tests
Browse files Browse the repository at this point in the history
Added testing, updated repeated_hadronize(), and ensured python 3.8 compliance
  • Loading branch information
jacanchaplais authored Jan 13, 2023
2 parents a7ad2c9 + ef11652 commit 5ce72bd
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 17 deletions.
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: tests

on:
- push
- pull_request

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
pyver: ["3.8", "3.9", "3.10"]
steps:
- uses: actions/checkout@v3

- name: Install Conda environment from environment.yml
uses: mamba-org/provision-with-micromamba@main
with:
cache-env: true
extra-specs: |
python=${{ matrix.pyver }}
pytest=6.2.5
hypothesis=6.62.0
- name: Install showerpipe
shell: bash -l {0}
run: pip install .

- name: Run tests
shell: bash -l {0}
run: pytest
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
.hypothesis/

# Translations
*.mo
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# showerpipe

[![PyPI version](https://img.shields.io/pypi/v/showerpipe.svg)](https://pypi.org/project/showerpipe/)
![Tests](https://github.com/jacanchaplais/showerpipe/actions/workflows/tests.yml/badge.svg)
[![Documentation](https://readthedocs.org/projects/showerpipe/badge/?version=latest)](https://showerpipe.readthedocs.io)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dev =
tox ==3.24.3
pytest ==6.2.5
pytest-cov ==2.12.1
hypothesis ==6.62.0
mypy ==0.910

[options.package_data]
Expand Down
4 changes: 2 additions & 2 deletions showerpipe/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
Provides types and abstract base classes to define the showerpipe API.
"""
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Iterable, Any
from collections.abc import Sized, Iterator
from typing import TypeVar, Generic, Iterable, Any, Iterator
from collections.abc import Sized

import numpy.typing as npt
import numpy as np
Expand Down
46 changes: 31 additions & 15 deletions showerpipe/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@
arrays.
"""
import os
import tempfile
import tempfile as tf
import shutil
from pathlib import Path
from typing import Optional, Any, Iterable, Dict, Tuple, Union
import typing as ty
from collections import OrderedDict
from dataclasses import dataclass
import operator as op
import random
from functools import cached_property
from copy import deepcopy
import warnings

import numpy as np
import numpy.lib.recfunctions as rfn
Expand Down Expand Up @@ -124,7 +125,7 @@ class PythiaEvent(base.EventAdapter):
_event: _pythia8.Event

@cached_property
def _pcls(self) -> Tuple[Any, ...]:
def _pcls(self) -> ty.Tuple[ty.Any, ...]:
return tuple(filter(lambda pcl: pcl.id() != 90, self._event))

def __len__(self) -> int:
Expand All @@ -141,11 +142,11 @@ def __str__(self) -> str:
def __rich__(self) -> str:
return str(self)

def _prop_map(self, prp: str) -> Iterable[Tuple[Any, ...]]:
def _prop_map(self, prp: str) -> ty.Iterable[ty.Tuple[ty.Any, ...]]:
return map(op.methodcaller(prp), self._pcls)

def _extract_struc(
self, schema: OrderedDict[str, Tuple[str, npt.DTypeLike]]
self, schema: ty.OrderedDict[str, ty.Tuple[str, npt.DTypeLike]]
) -> base.AnyVector:
dtype = np.dtype(list(schema.values()))
return np.fromiter(zip(*map(self._prop_map, schema.keys())), dtype)
Expand Down Expand Up @@ -234,6 +235,9 @@ class PythiaGenerator(base.GeneratorAdapter):
string, or bytes object. If file, may be compressed with gzip.
rng_seed : int
Seed passed to the random number generator used by Pythia.
quiet : bool
Whether to quieten ``pythia8`` during data generation. Default
is ``True``.
Attributes
----------
Expand All @@ -260,18 +264,19 @@ class PythiaGenerator(base.GeneratorAdapter):

def __init__(
self,
config_file: Union[str, Path],
lhe_file: Optional[lhe._LHE_STORAGE] = None,
rng_seed: Optional[int] = -1,
config_file: ty.Union[str, Path],
lhe_file: ty.Optional[lhe._LHE_STORAGE] = None,
rng_seed: ty.Optional[int] = -1,
quiet: bool = True,
) -> None:
if rng_seed is None:
rng_seed = random.randint(1, 900_000_000)
elif rng_seed < -1:
raise ValueError("rng_seed must be between -1 and 900_000_000.")
xml_dir = os.environ["PYTHIA8DATA"]
pythia = _pythia8.Pythia(xmlDir=xml_dir, printBanner=False)
config: Dict[str, Dict[str, str]] = {
"Print": {"quiet": "on"},
config: ty.Dict[str, ty.Dict[str, str]] = {
"Print": {"quiet": "on" if quiet else "off"},
"Random": {"setSeed": "on", "seed": str(rng_seed)},
"Beams": {"frameType": "4"},
}
Expand All @@ -286,7 +291,7 @@ def __init__(
if lhe_file is not None:
self._num_events = lhe.count_events(lhe_file)
with lhe.source_adapter(lhe_file) as f:
self.temp_lhe_file = tempfile.NamedTemporaryFile()
self.temp_lhe_file = tf.NamedTemporaryFile()
shutil.copyfileobj(f, self.temp_lhe_file)
self.temp_lhe_file.seek(0)
me_path = self.temp_lhe_file.name
Expand Down Expand Up @@ -353,8 +358,8 @@ def overwrite_event(self, new_event: PythiaEvent) -> None:


def repeat_hadronize(
gen: PythiaGenerator, reps: Optional[int] = None, copy: bool = True
) -> Iterable[PythiaEvent]:
gen: PythiaGenerator, reps: ty.Optional[int] = None, copy: bool = True
) -> ty.Generator[PythiaEvent, None, None]:
"""Takes a PythiaGenerator instance with an unhadronised event
already generated, and repeatedly hadronises the current event.
Expand Down Expand Up @@ -397,6 +402,9 @@ def repeat_hadronize(
ValueError
If the cmnd file used to initialise ``gen`` does not set
'HadronLevel:all = off'. See notes for more information.
UserWarning
If Pythia fails to perform the hadronization process for a given
event, this iterator will be empty.
Notes
-----
Expand Down Expand Up @@ -425,7 +433,9 @@ def repeat_hadronize(
if hadron_key not in conf_copy:
hadron_level = "on"
else:
hadron_level = conf_copy[hadron_key].pop("all", "on")
hadron_level = conf_copy[hadron_key].pop("all", None)
if hadron_level is None:
hadron_level = conf_copy[hadron_key].pop("Hadronize", "on")
if len(conf_copy[hadron_key]) > 0:
raise KeyError(
f"Conflicting settings for {hadron_key} provided, "
Expand All @@ -447,7 +457,13 @@ def repeat_hadronize(
while (reps is None) or (i < reps):
if gen._fresh_event is True: # stop if entirely new event generated
break
gen._pythia.forceHadronLevel()
success: bool = gen._pythia.forceHadronLevel()
if success is False:
gen.overwrite_event(event_copy)
warnings.warn(
"Pythia cannot hadronize event. Iterator empty.", UserWarning
)
break
event_out = gen._event
if copy is True:
event_out = event_out.copy()
Expand Down
197 changes: 197 additions & 0 deletions tests/test_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import tempfile as tf
import contextlib as ctx
from pathlib import Path
import operator as op
import itertools as it
import typing as ty
import shutil

import pytest
from hypothesis import given, settings, strategies as st
import numpy as np
import numpy.lib.recfunctions as rfn

import showerpipe as shp


TEST_DIR = Path(__file__).parent.resolve()
DATA_PATH = TEST_DIR / "tt_bb_100.lhe.gz"


def all_equal(iterable: ty.Iterable[ty.Any]) -> bool:
"""Returns True if all the elements are equal to each other."""
g = it.groupby(iterable)
return next(g, True) and not next(g, False)


@ctx.contextmanager
def config_file(
isr: bool = True, fsr: bool = True, mpi: bool = True, hadron: bool = True
) -> ty.Generator[Path, None, None]:
"""Context manager creates a temporary config file, and yields the
path, for the purpose of instantiating a new ``PythiaGenerator``.
Parameters
----------
isr : bool
Initial state radiation. Default is ``True``.
fsr : bool
Final state radiation. Default is ``True``.
mpi : bool
Multi-parton interaction. Default is ``True``.
hadron : bool
Hadronization. Default is ``True``.
"""
f = tf.NamedTemporaryFile("w")
switch = {True: "on", False: "off"}
f.write(f"PartonLevel:ISR = {switch[isr]}\n")
f.write(f"PartonLevel:FSR = {switch[fsr]}\n")
f.write(f"PartonLevel:MPI = {switch[mpi]}\n")
if hadron is True:
f.write("HadronLevel:Hadronize = on\n")
else:
f.write("HadronLevel:all = off\n")
f.seek(0)
try:
yield Path(f.name)
finally:
f.close()


@st.composite
def generators(
draw: st.DrawFn,
min_seed: int = 1,
max_seed: int = 10_000,
**config_kwargs: bool,
) -> shp.generator.PythiaGenerator:
"""Custom strategy providing a ``PythiaGenerator`` with a random
seed.
"""
seed = draw(st.integers(min_seed, max_seed))
with config_file(**config_kwargs) as conf_path:
return shp.generator.PythiaGenerator(conf_path, DATA_PATH, seed, False)


@given(generators())
@settings(max_examples=5, deadline=None)
def test_gen_len(gen: shp.generator.PythiaGenerator) -> None:
"""Tests that the generator length matches the number of events."""
assert len(gen) == 100


@given(generators())
@settings(max_examples=5, deadline=None)
def test_event_len(gen: shp.generator.PythiaGenerator) -> None:
"""Tests that the event length and data lengths match."""
event = next(gen)
event_len = len(event)
prop_names = (
"edges",
"pmu",
"color",
"pdg",
"final",
"helicity",
"status",
)
props = (prop(event) for prop in map(op.attrgetter, prop_names))
lens = tuple(map(len, props))
assert event_len != 0 and all_equal(lens) and lens[0] == event_len


@st.composite
def hadron_repeater(
draw: st.DrawFn,
min_seed: int = 1,
max_seed: int = 10_000,
reps: int = 100,
) -> ty.Iterable[shp.generator.PythiaEvent]:
"""Custom strategy, providing a repeated hadronization generator
with a random seed, and a user-defined number of repetitions.
"""
gen = draw(generators(min_seed, max_seed, hadron=False))
_ = next(gen)
return shp.generator.repeat_hadronize(gen, reps=reps)


@given(gen=generators(hadron=True))
@settings(max_examples=1, deadline=None)
def test_rep_hadron_conf_valerr(gen: shp.generator.PythiaGenerator) -> None:
"""Tests if ``ValueError`` is raised for incorrect config."""
_ = next(gen)
with pytest.raises(ValueError):
hadron_gen = shp.generator.repeat_hadronize(gen, 10, False)
_ = next(hadron_gen)


def test_rep_hadron_conf_keyerr() -> None:
"""Tests if ``KeyError`` is raised for incorrect config."""
with ctx.ExitStack() as stack:
stack.enter_context(pytest.raises(KeyError))
conf = stack.enter_context(tf.NamedTemporaryFile("wb"))
prelim_path = stack.enter_context(config_file())
prelim_conf = stack.enter_context(open(prelim_path, "rb"))
shutil.copyfileobj(prelim_conf, conf)
conf.write(b"HadronLevel:all = off")
conf.seek(0)
gen = shp.generator.PythiaGenerator(conf.name, DATA_PATH)
_ = next(gen)
hadron_gen = shp.generator.repeat_hadronize(gen, 10, False)
_ = next(hadron_gen)


@given(gen=generators(hadron=False))
@settings(max_examples=1, deadline=None)
def test_rep_hadron_conf_runerr(gen: shp.generator.PythiaGenerator) -> None:
"""Tests if ``RuntimeError`` is raised for instantiating without
existing event."""
with pytest.raises(RuntimeError):
hadron_gen = shp.generator.repeat_hadronize(gen, 10, False)
_ = next(hadron_gen)


@given(gen=generators(hadron=False))
@settings(max_examples=1, deadline=None)
def test_rep_hadron_conf_stopit(gen: shp.generator.PythiaGenerator) -> None:
"""Tests if ``StopIteration`` is raised for iterating the
``PythiaGenerator`` before between iterations of the repeat
hadronization."""
_ = next(gen)
with pytest.raises(StopIteration):
hadron_gen = shp.generator.repeat_hadronize(gen, 10, False)
_ = next(hadron_gen)
_ = next(gen)
_ = next(hadron_gen)


@given(gen=hadron_repeater())
@settings(max_examples=20, deadline=None)
def test_rep_hadron_uniq(gen: ty.Iterable[shp.generator.PythiaEvent]) -> None:
"""Tests if the events following repeated hadronization are unique."""
data = sorted(map(op.attrgetter("pdg"), gen), key=len)
for _, data_arrays in it.groupby(data, len):
data_pairs = it.combinations(data_arrays, 2)
assert any(it.starmap(np.array_equal, data_pairs)) is False


def rep_hadron_colorless(gen: ty.Iterable[shp.generator.PythiaEvent]) -> bool:
"""Given a rehadronization generator, return ``False`` if colored
particles make it to the final state.
"""
for event in gen:
final_colors = rfn.structured_to_unstructured(event.color[event.final])
if not np.all(final_colors == 0):
return False
return True


@given(gen=generators(hadron=False))
@settings(max_examples=5, deadline=None)
def test_rep_hadron_colorless(gen: shp.generator.PythiaGenerator) -> None:
"""Tests if colored particles make it to final state after
rehadronization. Repeats for successive hard events.
"""
for _ in it.islice(gen, 50):
hadron_gen = shp.generator.repeat_hadronize(gen, 10, False)
assert rep_hadron_colorless(hadron_gen) is True
Binary file added tests/tt_bb_100.lhe.gz
Binary file not shown.

0 comments on commit 5ce72bd

Please sign in to comment.