diff --git a/extras/matching/check-matching.py b/extras/matching/check-matching.py index e080883cb..f83cc714b 100644 --- a/extras/matching/check-matching.py +++ b/extras/matching/check-matching.py @@ -121,7 +121,7 @@ def collect_data(): data = {} pdf = toy.mkPDF("", 0) for id in th_updates.keys(): - with eko.EKO.open(f"./eko_{id}.tar") as evolution_operator: + with eko.EKO.read(f"./eko_{id}.tar") as evolution_operator: x = evolution_operator.metadata.rotations.targetgrid.raw data[id] = { mu2: el["pdfs"] diff --git a/src/eko/io/access.py b/src/eko/io/access.py index b8dec50a8..5a4d631c0 100644 --- a/src/eko/io/access.py +++ b/src/eko/io/access.py @@ -43,7 +43,7 @@ class ClosedOperator(RuntimeError, exceptions.OutputError): class AccessConfigs: """Configurations specified during opening of an EKO.""" - path: Path + path: Optional[Path] """The path to the permanent object.""" readonly: bool "Read-only flag" diff --git a/src/eko/io/legacy.py b/src/eko/io/legacy.py index 5b15f154d..41dc8af85 100644 --- a/src/eko/io/legacy.py +++ b/src/eko/io/legacy.py @@ -2,7 +2,6 @@ import dataclasses import io -import os import pathlib import tarfile import tempfile @@ -32,7 +31,7 @@ _MT = 172.5 -def load_tar(source: os.PathLike, dest: os.PathLike, errors: bool = False): +def load_tar(source: pathlib.Path, dest: pathlib.Path, errors: bool = False): """Load tar representation from file. Compliant with :meth:`dump_tar` output. diff --git a/src/eko/io/metadata.py b/src/eko/io/metadata.py index ce8b13998..c95bbfcb5 100644 --- a/src/eko/io/metadata.py +++ b/src/eko/io/metadata.py @@ -39,7 +39,7 @@ class Metadata(DictLike): """Interpolation grid""" # tagging information _path: Optional[pathlib.Path] = None - """Path to temporary dir.""" + """Path to the open dir.""" version: str = vmod.__version__ """Library version used to create the corresponding file.""" data_version: int = vmod.__data_version__ diff --git a/src/eko/io/struct.py b/src/eko/io/struct.py index fe3bf7c6e..9ee1b73f9 100644 --- a/src/eko/io/struct.py +++ b/src/eko/io/struct.py @@ -3,12 +3,11 @@ import contextlib import copy import logging -import os -import pathlib import shutil import tarfile import tempfile from dataclasses import dataclass +from pathlib import Path from typing import List, Optional import numpy as np @@ -30,7 +29,7 @@ TEMP_PREFIX = "eko-" -def inventories(path: pathlib.Path, access: AccessConfigs) -> dict: +def inventories(path: Path, access: AccessConfigs) -> dict: """Set up empty inventories for object initialization.""" paths = InternalPaths(path) return dict( @@ -290,7 +289,7 @@ def unload(self): # operator management # ------------------- - def deepcopy(self, path: os.PathLike): + def deepcopy(self, path: Path): """Create a deep copy of current instance. The managed on-disk object is copied as well, to the new ``path`` @@ -324,115 +323,101 @@ def deepcopy(self, path: os.PathLike): self.unload() new = copy.deepcopy(self) - new.access.path = pathlib.Path(path) + new.access.path = path new.access.readonly = False new.access.open = True - tmpdir = pathlib.Path(tempfile.mkdtemp(prefix=TEMP_PREFIX)) + tmpdir = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX)) new.metadata.path = tmpdir # copy old dir to new dir tmpdir.rmdir() shutil.copytree(self.paths.root, new.paths.root) new.close() - @staticmethod - def load(tarpath: os.PathLike, dest: os.PathLike): - """Load the content of archive in a target directory. + @classmethod + def load(cls, path: Path): + """Load the EKO from disk information. - Parameters - ---------- - tarpath: os.PathLike - the archive to extract - tmppath: os.PathLike - the destination directory + Note + ---- + No archive path is assigned to the :class:`EKO` object, setting its + :attr:`EKO.access.path` to `None`. + If you want to properly load from an archive, use the :meth:`read` + constructor. """ - try: - with tarfile.open(tarpath) as tar: - raw.safe_extractall(tar, dest) - except tarfile.ReadError: - raise exceptions.OutputNotTar(f"Not a valid tar archive: '{tarpath}'") + access = AccessConfigs(None, readonly=True, open=True) - @classmethod - def open(cls, path: os.PathLike, mode="r"): - """Open EKO object in the specified mode.""" - path = pathlib.Path(path) - access = AccessConfigs(path, readonly=False, open=True) - load = False - if mode == "r": - load = True - access.readonly = True - elif mode in "w": - pass - elif mode in "a": - load = True - else: - raise ValueError(f"Unknown file mode: {mode}") - - tmpdir = pathlib.Path(tempfile.mkdtemp(prefix=TEMP_PREFIX)) - if not load: - return Builder(path=tmpdir, access=access) - # load existing instead - cls.load(path, tmpdir) - metadata = Metadata.load(tmpdir) - opened: EKO = cls( - **inventories(tmpdir, access), + metadata = Metadata.load(path) + loaded = cls( + **inventories(path, access), metadata=metadata, access=access, ) - opened.operators.sync() + loaded.operators.sync() - return opened + return loaded @classmethod - def read(cls, path: os.PathLike): - """Read the content of an EKO. + def read( + cls, + path: Path, + extract: bool = True, + dest: Optional[Path] = None, + readonly: bool = True, + ): + """Load an existing EKO. + + If the `extract` attribute is `True` the EKO is loaded from its archived + format. Otherwise, the `path` is interpreted as the location of an + already extracted folder. - Type-safe alias for:: - EKO.open(... , "r") """ - eko = cls.open(path, "r") - assert isinstance(eko, EKO) - return eko + if extract: + dir_ = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX)) if dest is None else dest + with tarfile.open(path) as tar: + raw.safe_extractall(tar, dir_) + else: + dir_ = path - @classmethod - def create(cls, path: os.PathLike): - """Create a new EKO. + loaded = cls.load(dir_) - Type-safe alias for:: + loaded.access.readonly = readonly + if extract: + loaded.access.path = path - EKO.open(... , "w") + return loaded - """ - builder = cls.open(path, "w") - assert isinstance(builder, Builder) + @classmethod + def create(cls, path: Path): + """Create a new EKO.""" + access = AccessConfigs(path, readonly=False, open=True) + builder = Builder( + path=Path(tempfile.mkdtemp(prefix=TEMP_PREFIX)), access=access + ) return builder @classmethod - def edit(cls, path: os.PathLike): + def edit(cls, *args, **kwargs): """Read from and write on existing EKO. - Type-safe alias for:: - - EKO.open(... , "a") + Alias of `EKO.read(..., readonly=False)`, see :meth:`read`. """ - eko = cls.open(path, "a") - assert isinstance(eko, EKO) - return eko + return cls.read(*args, readonly=False, **kwargs) def __enter__(self): """Allow EKO to be used in :obj:`with` statements.""" return self - def dump(self, archive: Optional[os.PathLike] = None): + def dump(self, archive: Optional[Path] = None): """Dump the current content to archive. Parameters ---------- - archive: os.PathLike or None + archive: Path or None path to archive, in general you should keep the default, that will make use of the registered path (default: ``None``) @@ -471,7 +456,8 @@ def __exit__(self, exc_type: type, _exc_value, _traceback): if exc_type is not None: return - self.close() + if self.access.path is not None: + self.close() @property def raw(self) -> dict: @@ -491,8 +477,8 @@ def raw(self) -> dict: class Builder: """Build EKO instances.""" - path: pathlib.Path - """Path on disk to .""" + path: Path + """Path on disk to the EKO.""" access: AccessConfigs """Access related configurations.""" diff --git a/src/eko/runner/__init__.py b/src/eko/runner/__init__.py index 5f1036c7b..1c331d17b 100644 --- a/src/eko/runner/__init__.py +++ b/src/eko/runner/__init__.py @@ -1,6 +1,6 @@ """Manage steps to DGLAP solution, and operator creation.""" -import os +from pathlib import Path from typing import Union from ..io.runcards import OperatorCard, TheoryCard @@ -15,7 +15,7 @@ def solve( theory_card: Union[RawCard, TheoryCard], operators_card: Union[RawCard, OperatorCard], - path: os.PathLike, + path: Path, ): r"""Solve DGLAP equations in terms of evolution kernel operators (EKO). diff --git a/src/eko/runner/legacy.py b/src/eko/runner/legacy.py index b6e9bf1c4..44f91670b 100644 --- a/src/eko/runner/legacy.py +++ b/src/eko/runner/legacy.py @@ -1,7 +1,7 @@ """Main application class of eko.""" import logging -import os +from pathlib import Path from typing import Union from ekomark.data import update_runcards @@ -32,7 +32,7 @@ def __init__( self, theory_card: Union[RawCard, runcards.TheoryCard], operators_card: Union[RawCard, runcards.OperatorCard], - path: os.PathLike, + path: Path, ): """Initialize runner. diff --git a/src/eko/runner/managed.py b/src/eko/runner/managed.py index df5223700..c50012ffd 100644 --- a/src/eko/runner/managed.py +++ b/src/eko/runner/managed.py @@ -13,39 +13,31 @@ from pathlib import Path -from ..io.items import Evolution, Matching, Target +from ..io.items import Target from ..io.runcards import OperatorCard, TheoryCard from ..io.struct import EKO -from . import commons, operators, parts, recipes +from . import operators, parts, recipes def solve(theory: TheoryCard, operator: OperatorCard, path: Path): """Solve DGLAP equations in terms of evolution kernel operators (EKO).""" with EKO.create(path) as builder: eko = builder.load_cards(theory, operator).build() # pylint: disable=E1101 - - atlas = commons.atlas(eko.theory_card, eko.operator_card) - - recs = recipes.create(eko.operator_card.evolgrid, atlas) - eko.load_recipes(recs) + recipes.create(eko) for recipe in eko.recipes: - assert isinstance(recipe, Evolution) eko.parts[recipe] = parts.evolve(eko, recipe) # flush the memory del eko.parts[recipe] for recipe in eko.recipes_matching: - assert isinstance(recipe, Matching) eko.parts_matching[recipe] = parts.match(eko, recipe) # flush the memory del eko.parts_matching[recipe] for ep in operator.evolgrid: - headers = recipes.elements(ep, atlas) - parts_ = operators.retrieve(headers, eko.parts, eko.parts_matching) + components = operators.retrieve(ep, eko) target = Target.from_ep(ep) - eko.operators[target] = operators.join(parts_) + eko.operators[target] = operators.join(components) # flush the memory del eko.parts - del eko.parts_matching del eko.operators[target] diff --git a/src/eko/runner/operators.py b/src/eko/runner/operators.py index 1efa8bb07..a315f6964 100644 --- a/src/eko/runner/operators.py +++ b/src/eko/runner/operators.py @@ -8,9 +8,12 @@ from ..io.inventory import Inventory from ..io.items import Evolution, Operator, Recipe +from ..io.struct import EKO +from ..io.types import EvolutionPoint +from . import commons, recipes -def retrieve( +def _retrieve( headers: List[Recipe], parts: Inventory, parts_matching: Inventory ) -> List[Operator]: """Retrieve parts to be joined.""" @@ -24,7 +27,18 @@ def retrieve( return elements -def dot4(op1: npt.NDArray, op2: npt.NDArray) -> npt.NDArray: +def _parts(ep: EvolutionPoint, eko: EKO) -> List[Recipe]: + """Determine parts required for the given evolution point operator.""" + atlas = commons.atlas(eko.theory_card, eko.operator_card) + return recipes._elements(ep, atlas) + + +def retrieve(ep: EvolutionPoint, eko: EKO) -> List[Operator]: + """Retrieve parts required for the given evolution point operator.""" + return _retrieve(_parts(ep, eko), eko.parts, eko.parts_matching) + + +def _dot4(op1: npt.NDArray, op2: npt.NDArray) -> npt.NDArray: """Dot product between rank 4 objects. The product is performed considering them as matrices indexed by pairs, so @@ -34,10 +48,10 @@ def dot4(op1: npt.NDArray, op2: npt.NDArray) -> npt.NDArray: return np.einsum("aibj,bjck->aick", op1, op2) -def dotop(op1: Operator, op2: Operator) -> Operator: +def _dotop(op1: Operator, op2: Operator) -> Operator: r"""Dot product between two operators. - Essentially a wrapper of :func:`dot4`, applying linear error propagation, + Essentially a wrapper of :func:`_dot4`, applying linear error propagation, if applicable. Note @@ -67,10 +81,10 @@ def dotop(op1: Operator, op2: Operator) -> Operator: |da_i| \cdot |b_i| + |a_i| \cdot |db_i| + \mathcal{O}(d^2) """ - val = dot4(op1.operator, op2.operator) + val = _dot4(op1.operator, op2.operator) if op1.error is not None and op2.error is not None: - err = dot4(np.abs(op1.operator), np.abs(op2.error)) + dot4( + err = _dot4(np.abs(op1.operator), np.abs(op2.error)) + _dot4( np.abs(op1.error), np.abs(op2.operator) ) else: @@ -93,4 +107,4 @@ def join(elements: List[Operator]) -> Operator: consider if reversing the path... """ - return reduce(dotop, reversed(elements)) + return reduce(_dotop, reversed(elements)) diff --git a/src/eko/runner/parts.py b/src/eko/runner/parts.py index 18d22f377..75c4d8db8 100644 --- a/src/eko/runner/parts.py +++ b/src/eko/runner/parts.py @@ -23,7 +23,7 @@ from . import commons -def managers(eko: EKO) -> Managers: +def _managers(eko: EKO) -> Managers: """Collect managers for operator computation. .. todo:: @@ -39,7 +39,7 @@ def managers(eko: EKO) -> Managers: ) -def evolve_configs(eko: EKO) -> dict: +def _evolve_configs(eko: EKO) -> dict: """Create configs for :class:`Operator`. .. todo:: @@ -70,7 +70,10 @@ def evolve_configs(eko: EKO) -> dict: def evolve(eko: EKO, recipe: Evolution) -> Operator: """Compute evolution in isolation.""" op = evop.Operator( - evolve_configs(eko), managers(eko), recipe.as_atlas, is_threshold=recipe.cliff + _evolve_configs(eko), + _managers(eko), + recipe.as_atlas, + is_threshold=recipe.cliff, ) op.compute() @@ -82,7 +85,7 @@ def evolve(eko: EKO, recipe: Evolution) -> Operator: return Operator(res, err) -def matching_configs(eko: EKO) -> dict: +def _matching_configs(eko: EKO) -> dict: """Create configs for :class:`OperatorMatrixElement`. .. todo:: @@ -92,7 +95,7 @@ def matching_configs(eko: EKO) -> dict: tcard = eko.theory_card ocard = eko.operator_card return dict( - **evolve_configs(eko), + **_evolve_configs(eko), backward_inversion=ocard.configs.inversion_method, ) @@ -111,8 +114,8 @@ def match(eko: EKO, recipe: Matching) -> Operator: """ kthr = eko.theory_card.heavy.squared_ratios[recipe.hq - 4] op = ome.OperatorMatrixElement( - matching_configs(eko), - managers(eko), + _matching_configs(eko), + _managers(eko), recipe.hq - 1, recipe.scale, recipe.inverse, diff --git a/src/eko/runner/recipes.py b/src/eko/runner/recipes.py index f340b9dce..1f83e75dd 100644 --- a/src/eko/runner/recipes.py +++ b/src/eko/runner/recipes.py @@ -3,11 +3,13 @@ from typing import List from ..io.items import Evolution, Matching, Recipe +from ..io.struct import EKO from ..io.types import EvolutionPoint as EPoint from ..matchings import Atlas, Segment +from . import commons -def elements(ep: EPoint, atlas: Atlas) -> List[Recipe]: +def _elements(ep: EPoint, atlas: Atlas) -> List[Recipe]: """Determine recipes to compute a given operator.""" recipes = [] @@ -27,10 +29,17 @@ def elements(ep: EPoint, atlas: Atlas) -> List[Recipe]: return recipes -def create(evolgrid: List[EPoint], atlas: Atlas) -> List[Recipe]: +def _create(evolgrid: List[EPoint], atlas: Atlas) -> List[Recipe]: """Create all associated recipes.""" recipes = [] for ep in evolgrid: - recipes.extend(elements(ep, atlas)) + recipes.extend(_elements(ep, atlas)) return list(set(recipes)) + + +def create(eko: EKO): + """Create and load all associated recipes.""" + atlas = commons.atlas(eko.theory_card, eko.operator_card) + recs = _create(eko.operator_card.evolgrid, atlas) + eko.load_recipes(recs) diff --git a/src/ekobox/utils.py b/src/ekobox/utils.py index 10ee536a3..5a00314e7 100644 --- a/src/ekobox/utils.py +++ b/src/ekobox/utils.py @@ -1,7 +1,7 @@ """Generic utilities to work with EKOs.""" -import os from collections import defaultdict +from pathlib import Path from typing import Optional import numpy as np @@ -16,7 +16,7 @@ def ekos_product( eko_fin: EKO, rtol: float = 1e-6, atol: float = 1e-10, - path: Optional[os.PathLike] = None, + path: Optional[Path] = None, ): """Compute the product of two ekos. diff --git a/tests/eko/io/test_struct.py b/tests/eko/io/test_struct.py index b7ff9877b..1370ccdb6 100644 --- a/tests/eko/io/test_struct.py +++ b/tests/eko/io/test_struct.py @@ -28,16 +28,14 @@ def test_new_error(self, tmp_path: pathlib.Path, theory_card, operator_card): for args in [(None, None), (theory_card, None), (None, operator_card)]: with pytest.raises(RuntimeError, match="missing"): with struct.EKO.create(tmp_path / "Blub2.tar") as builder: - eko = builder.load_cards(*args).build() + _ = builder.load_cards(*args).build() def test_load_error(self, tmp_path): # try to read from a non-tar path no_tar_path = tmp_path / "Blub.tar" no_tar_path.write_text("Blub", encoding="utf-8") - with pytest.raises(ValueError, match="tar"): + with pytest.raises(tarfile.ReadError): struct.EKO.read(no_tar_path) - with pytest.raises(ValueError, match="file mode"): - struct.EKO.open(no_tar_path, "ΓΌ") def test_properties(self, eko_factory: EKOFactory): mu = 10.0 @@ -184,3 +182,16 @@ def test_context_operator(self, eko_factory: EKOFactory): assert isinstance(op, struct.Operator) assert eko.operators.cache[Target.from_ep(ep)] is None + + def test_load_opened(self, tmp_path: pathlib.Path, eko_factory: EKOFactory): + """Test the loading of an already opened EKO.""" + eko = eko_factory.get() + eko.close() + # drop from cache to avoid double close by the fixture + eko_factory.cache = None + + assert eko.access.path is not None + read_closed = EKO.read(eko.access.path, dest=tmp_path) + read_opened = EKO.read(tmp_path, extract=False) + + assert read_closed.metadata == read_opened.metadata diff --git a/tests/eko/runner/conftest.py b/tests/eko/runner/conftest.py index a2966c5e9..3b3bac0b3 100644 --- a/tests/eko/runner/conftest.py +++ b/tests/eko/runner/conftest.py @@ -29,7 +29,7 @@ def identity(neweko: EKO): @pytest.fixture def ekoparts(neweko: EKO, identity: Operator): atlas = commons.atlas(neweko.theory_card, neweko.operator_card) - neweko.load_recipes(recipes.create(neweko.operator_card.evolgrid, atlas)) + neweko.load_recipes(recipes._create(neweko.operator_card.evolgrid, atlas)) for rec in neweko.recipes: neweko.parts[rec] = identity diff --git a/tests/eko/runner/test_operators.py b/tests/eko/runner/test_operators.py index b69066da2..7a8b4cdb7 100644 --- a/tests/eko/runner/test_operators.py +++ b/tests/eko/runner/test_operators.py @@ -2,18 +2,18 @@ from eko.io.items import Operator from eko.io.struct import EKO -from eko.runner.operators import join, retrieve +from eko.runner.operators import _retrieve, join def test_retrieve(ekoparts: EKO): evhead, _evop = next(iter(ekoparts.parts.cache.items())) matchhead, _matchop = next(iter(ekoparts.parts_matching.cache.items())) - els = retrieve([evhead] * 5, ekoparts.parts, ekoparts.parts_matching) + els = _retrieve([evhead] * 5, ekoparts.parts, ekoparts.parts_matching) assert len(els) == 5 assert all(isinstance(el, Operator) for el in els) - els = retrieve( + els = _retrieve( [evhead, matchhead, matchhead], ekoparts.parts, ekoparts.parts_matching ) assert len(els) == 3 diff --git a/tests/eko/runner/test_parts.py b/tests/eko/runner/test_parts.py index 2ec217dd7..1c7dd105b 100644 --- a/tests/eko/runner/test_parts.py +++ b/tests/eko/runner/test_parts.py @@ -5,18 +5,18 @@ def test_evolve_configs(eko_factory): # QCD@LO e10 = eko_factory.get() assert e10.theory_card.order == (1, 0) - p10 = parts.evolve_configs(e10) + p10 = parts._evolve_configs(e10) assert p10["matching_order"] == (0, 0) # QCD@N3LO + QED@N2LO w/o matching_order eko_factory.theory.order = (4, 3) eko_factory.theory.matching_order = None e43 = eko_factory.get({}) assert e43.theory_card.order == (4, 3) - p43 = parts.evolve_configs(e43) + p43 = parts._evolve_configs(e43) assert p43["matching_order"] == (3, 0) # QCD@N3LO + QED@N2LO w/ matching_order eko_factory.theory.matching_order = (3, 0) e43b = eko_factory.get({}) assert e43b.theory_card.order == (4, 3) - p43b = parts.evolve_configs(e43b) + p43b = parts._evolve_configs(e43b) assert p43b["matching_order"] == (3, 0) diff --git a/tests/eko/runner/test_recipes.py b/tests/eko/runner/test_recipes.py index 27bf4e860..7b93ab4c4 100644 --- a/tests/eko/runner/test_recipes.py +++ b/tests/eko/runner/test_recipes.py @@ -4,26 +4,26 @@ from eko.io.types import EvolutionPoint from eko.matchings import Atlas from eko.quantities.heavy_quarks import MatchingScales -from eko.runner.recipes import create, elements +from eko.runner.recipes import _create, _elements SCALES = MatchingScales([10.0, 20.0, 30.0]) ATLAS = Atlas(SCALES, (50.0, 5)) def test_elements(): - onestep = elements((60.0, 5), ATLAS) + onestep = _elements((60.0, 5), ATLAS) assert len(onestep) == 1 assert isinstance(onestep[0], Evolution) assert not onestep[0].cliff - backandforth = elements((60.0, 6), ATLAS) + backandforth = _elements((60.0, 6), ATLAS) assert len(backandforth) == 3 assert isinstance(backandforth[0], Evolution) assert backandforth[0].cliff assert isinstance(backandforth[1], Matching) assert not backandforth[1].inverse - down = elements((5.0, 3), ATLAS) + down = _elements((5.0, 3), ATLAS) assert all([isinstance(el, Evolution) for i, el in enumerate(down) if i % 2 == 0]) assert all([isinstance(el, Matching) for i, el in enumerate(down) if i % 2 == 1]) assert all([el.inverse for i, el in enumerate(down) if i % 2 == 1]) @@ -32,13 +32,13 @@ def test_elements(): def test_create(): evolgrid: List[EvolutionPoint] = [(60.0, 5)] - recs = create(evolgrid, ATLAS) + recs = _create(evolgrid, ATLAS) assert len(recs) == 1 evolgrid.append((60.0, 6)) - recs = create(evolgrid, ATLAS) + recs = _create(evolgrid, ATLAS) assert len(recs) == 1 + 3 evolgrid.append((70.0, 6)) - recs = create(evolgrid, ATLAS) + recs = _create(evolgrid, ATLAS) assert len(recs) == 1 + 3 + 1