Source code for pynbody.halo.portable

"""Support for moving a halo catalogue between processes, or storing it independently of its files.

A halo catalogue normally depends on the files written by a halo finder, and on the machinery in the
corresponding :class:`~pynbody.halo.HaloCatalogue` subclass for interpreting them. That is inconvenient when a
catalogue needs to be handed to another process (for example by a parallel task manager such as `tangos
<https://pynbody.github.io/tangos/>`_, which loads a catalogue once and shares it between workers).

To make this possible, :meth:`~pynbody.halo.HaloCatalogue.get_portable_state` expresses everything that a
catalogue knows -- its halo numbering, the particles belonging to each halo, what it knows about particles
that are missing from the snapshot, and any halo finder properties -- as a dictionary of numpy arrays and
python primitives. That includes the limits of its knowledge: a catalogue which cannot detect missing
particles says so, rather than appearing to have become able to. The recipient does not need to understand
the role of any of these arrays; it needs only to get them to the other end (e.g. by putting them into shared
memory), where :meth:`~pynbody.halo.HaloCatalogue.from_portable_state` turns them back into a live catalogue,
attached to a specified simulation, in the form of a :class:`PortableHaloCatalogue`.

.. versionadded:: 2.7.0

"""

from __future__ import annotations

import warnings

import numpy as np

from .. import array, units
from . import HaloCatalogue
from .details.number_mapping import HaloNumberMapper
from .details.particle_indices import HaloParticleIndices

#: The version of the portable state format generated by this version of pynbody. It is incremented if a
#: change is made that older versions of pynbody cannot interpret.
PORTABLE_STATE_VERSION = 1

_PRIMITIVE_TYPES = (str, bytes, bool, int, float, complex, np.number, np.bool_, type(None))


[docs] class PortableHaloCatalogue(HaloCatalogue): """A halo catalogue reconstructed from the portable state of another halo catalogue. Instances are normally created by :meth:`~pynbody.halo.HaloCatalogue.from_portable_state` rather than directly. They behave like any other halo catalogue, except that they never refer back to the halo finder's files; all the information they have is what was in the state dictionary they were created from. .. versionadded:: 2.7.0 """ _uses_file_position_addressing = False """Never true, whatever the originating catalogue did. Halo membership arrives here as offsets into the snapshot the state was generated from, already resolved by that catalogue; nothing is expressed relative to a file. The requirement is instead that *sim* presents the same particles in the same order, which the caller asserts by passing it."""
[docs] def __init__(self, sim, state: dict): """Create a halo catalogue for *sim* from the *state* dictionary of another catalogue. Parameters ---------- sim : :class:`~pynbody.snapshot.simsnap.SimSnap` The simulation to attach the catalogue to. It must have the same particle ordering as the simulation the state was generated from, since halo membership is expressed as offsets into it. state : dict A dictionary previously returned by :meth:`~pynbody.halo.HaloCatalogue.get_portable_state`. """ version = state.get('version', PORTABLE_STATE_VERSION) if version > PORTABLE_STATE_VERSION: raise ValueError(f"This halo catalogue was exported in format version {version}, but this version " f"of pynbody understands only up to version {PORTABLE_STATE_VERSION}") super().__init__(sim, number_mapper=HaloNumberMapper.from_portable_state(state['number_mapper'])) self._index_lists = HaloParticleIndices.from_portable_state(state['particle_indices']) self._original_class_name = state.get('catalogue_class') # A catalogue which could not tell whether particles were missing does not become able to by being # transferred, so the capability travels with the state. Without this, such a catalogue would report # every halo as complete at the far end, and without the warning that says why. self._can_determine_completeness = state.get('can_determine_completeness', True) properties = state.get('properties') if properties: self._properties = properties_from_portable_state(properties)
def _get_all_particle_indices(self) -> HaloParticleIndices: return self._index_lists
[docs] def get_properties_all_halos(self, with_units=True) -> dict: if self._properties is None: return {} elif with_units: return self._properties else: return {k: _strip_units(v) for k, v in self._properties.items()}
def __repr__(self): if self._original_class_name is None: return super().__repr__() else: return f"<{type(self).__name__} from {self._original_class_name}, length {len(self)}>"
[docs] def map_arrays(state, mapper, types=np.ndarray): """Return a copy of a portable state, with every numpy array in it replaced by ``mapper(array)``. This is a convenience for code that transfers a state elsewhere, e.g. via shared memory. It saves the caller from having to know the structure of the state, let alone the meaning of the individual arrays. .. versionadded:: 2.7.0 Parameters ---------- state : dict | list | object A portable state, as returned by :meth:`~pynbody.halo.HaloCatalogue.get_portable_state`, or any part of one. mapper : callable A function which is called with each array in turn, and returns its replacement. types : type | tuple[type] The types to be passed to *mapper*; anything else is left alone. This defaults to numpy arrays, but may be changed to reverse a mapping that has replaced them with something else. """ if isinstance(state, types): return mapper(state) elif isinstance(state, dict): return {key: map_arrays(value, mapper, types) for key, value in state.items()} elif isinstance(state, list): return [map_arrays(value, mapper, types) for value in state] else: return state
[docs] def properties_to_portable_state(properties: dict) -> dict: """Express a dictionary of halo properties using only numpy arrays and python primitives. Properties which cannot be expressed in this way are omitted, with a warning. The result can be turned back into a properties dictionary by :func:`properties_from_portable_state`.""" state = {} for key, value in properties.items(): encoded = _property_to_portable_state(value) if encoded is None: warnings.warn(f"Halo property '{key}' is of a type that cannot be transferred between processes, " f"and has been omitted", RuntimeWarning) else: state[key] = encoded return state
[docs] def properties_from_portable_state(state: dict) -> dict: """Recreate a dictionary of halo properties from one returned by :func:`properties_to_portable_state`.""" return {key: _property_from_portable_state(value) for key, value in state.items()}
def _property_to_portable_state(value): """Encode a single halo property, or return None if it cannot be encoded""" unit_name = str(value.units) if units.has_unit(value) else None if isinstance(value, np.ndarray) and value.dtype != object: return {'kind': 'array', 'data': np.asarray(value), 'units': unit_name} if isinstance(value, _PRIMITIVE_TYPES): return {'kind': 'primitive', 'value': value, 'units': unit_name} encoded = _sequence_to_portable_state(value) if encoded is not None: encoded['units'] = unit_name return encoded def _sequence_to_portable_state(value): """Encode a list, tuple or object array, or return None if it cannot be encoded. Properties like 'children' are a ragged collection of arrays, one per halo. These are stored the same way as halo membership itself, i.e. concatenated together with a list of boundaries. A simple sequence of scalars is stored as a single array.""" if not isinstance(value, (list, tuple)) and not (isinstance(value, np.ndarray) and value.dtype == object): return None try: rows = [np.asarray(row) for row in value] except (TypeError, ValueError): return None if all(row.ndim == 0 and row.dtype != object for row in rows): return {'kind': 'sequence', 'data': np.asarray(value)} if any(row.ndim != 1 or row.dtype == object for row in rows): return None lengths = np.array([len(row) for row in rows], dtype=np.intp) stops = np.cumsum(lengths) boundaries = np.vstack((stops - lengths, stops)).T non_empty = [row for row in rows if len(row) > 0] if len(non_empty) > 0: data = np.concatenate(non_empty) elif len(rows) > 0: data = rows[0] else: data = np.zeros(0, dtype=np.intp) return {'kind': 'ragged', 'data': data, 'boundaries': boundaries} def _property_from_portable_state(state: dict): kind = state['kind'] unit_name = state.get('units') if kind == 'primitive': return _with_units(state['value'], unit_name) elif kind == 'array': return _with_units(np.asarray(state['data']), unit_name) elif kind == 'sequence': return [_with_units(value, unit_name) for value in np.asarray(state['data'])] elif kind == 'ragged': data = np.asarray(state['data']) return [_with_units(data[start:stop], unit_name) for start, stop in state['boundaries']] else: raise ValueError(f"Unknown halo property encoding '{kind}'") def _with_units(value, unit_name): if unit_name is None: return value elif isinstance(value, np.ndarray): return array.SimArray(value, units.Unit(unit_name)) else: return value * units.Unit(unit_name) def _strip_units(value): if isinstance(value, list): return [_strip_units(v) for v in value] elif isinstance(value, np.ndarray): return np.asarray(value) else: return value