Source code for apav.core.range

"""
This file is part of APAV.

APAV is a python package for performing analysis and visualization on
atom probe tomography data sets.

Copyright (C) 2018 Jesse Smith

APAV is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.

APAV is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with APAV.  If not, see <http://www.gnu.org/licenses/>.
"""

from typing import Sequence, Tuple, List, Dict, Any, Union, Type, Optional, TYPE_CHECKING
from numbers import Real, Number
from apav.core.isotopic import Element

from collections import OrderedDict
from configparser import ConfigParser
import copy

from tabulate import tabulate
import numpy as n

from apav.utils import helpers, validate
import apav as ap
from apav.utils.logging import log


[docs]class Range: """ A single mass spectrum range """ __next_id = 0 def __init__( self, ion: Union["ap.Ion", str], minmax: Tuple[Number, Number], vol: Number = 1, color: Tuple[Number, Number, Number] = (0, 0, 0), ): """ Define a singular mass spectrum range composed of a composition, interval, volume, and color. i.e. Created as: >>> cu = Range("Cu", (62, 66), color=(0.5, 1, 0.25)) :param ion: the range composition :param minmax: (min, max) tuple of the mass spectrum range :param vol: the "volume" of the atom used during reconstruction :param color: the color as RGB fractions """ super().__init__() if any(i < 0 for i in (minmax[0], minmax[1])): raise ValueError("Range limits cannot be negative") elif minmax[0] >= minmax[1]: raise ValueError("Range lower bound cannot be larger than range upper bound") if isinstance(ion, str): ion = ap.Ion(ion) elif not isinstance(ion, (ap.Ion, str)): raise TypeError(f"Range ion must be type Ion or string, not {type(ion)}") self._ion = ion self._lower = validate.positive_number(minmax[0]) self._upper = validate.positive_nonzero_number(minmax[1]) self._color = validate.color_as_rgb(color) self._vol = validate.positive_number(vol) self._id = Range.__next_id Range.__next_id += 1 def __contains__(self, mass: float) -> bool: """ Be able test if range contains a mass ratio """ return self.contains_mass(mass) def __repr__(self): retn = f"Range: {self.hill_formula}," col = [round(i, 2) for i in self.color] retn += f" Min: {self.lower}, Max: {self.upper}, Vol: {self.vol}, Color: {col}" return retn def __eq__(self, other: "Range"): if not isinstance(other, Range): return NotImplemented if other.ion == self.ion and n.isclose(other.lower, self.lower) and n.isclose(other.upper, self.upper): return True else: return NotImplemented @property def id(self) -> int: return self._id @property def lower(self) -> Number: """ Get the lower (closed) boundary of the range """ return self._lower @lower.setter def lower(self, new: Number): """ Set the lower (closed) boundary of the range :param new: :return: """ validate.positive_number(new) if new >= self._upper: raise ValueError(f"Lower bound for {self.ion} ({new}) cannot be >= upper bound ({self.upper})") self._lower = new @property def upper(self) -> Number: """ Get the upper (open) boundary of the range """ return self._upper @upper.setter def upper(self, new: Number): """ Set the upper (open) boundary of the range """ validate.positive_number(new) if new <= self._lower: raise ValueError(f"Upper bound for {self.ion} ({new}) cannot be <= lower bound ({self.lower})") self._upper = new @property def color(self) -> Tuple[Number, Number, Number]: """ Get the color of the range as (R, G, B) tuple. Values range from 0-1 """ return self._color @color.setter def color(self, new: Tuple[Number, Number, Number]): """ Set the color of the range. Color must be a Tuple(reg, green, blue) where RGB values are between 0-1 """ self._color = validate.color_as_rgb(new) @property def interval(self) -> Tuple[Number, Number]: """ Get the (min, max) interval defined the mass spectrum range """ return self.lower, self.upper @property def vol(self) -> Number: """ Get the volume of the range """ return self._vol @vol.setter def vol(self, new: Number): """ Set the volume of the range :param new: the new volume """ self._vol = validate.positive_nonzero_number(new)
[docs] def num_elems(self) -> int: """ Get the number of unique elements of the range composition """ return len(self.ion.elements)
@property def ion(self) -> "ap.Ion": """ Get a tuple of the elements that compose this range """ return self._ion @ion.setter def ion(self, new: Union["ap.Ion", str]): """ Set the composition of the range :param new: the new composition """ if not isinstance(new, (str, ap.Ion)): raise TypeError(f"Expected type Ion or string not {type(new)}") if isinstance(new, str): self._ion = ap.Ion(new) else: self._ion = new @property def hill_formula(self) -> str: """ Get the range composition as a string """ return self.ion.hill_formula @property def formula(self) -> str: """ Get the range composition as a string """ return self.ion.hill_formula.replace(" ", "")
[docs] def intersects(self, rng: "Range"): """ Determine if the range intersects a given :class:`Range` """ if self.lower <= rng.lower < self.upper: return True elif self.lower < rng.upper < self.upper: return True else: return False
[docs] def contains_mass(self, mass: Number) -> bool: """ Test if the given mass/charge ratio is contained within range's bounds :param mass: mass/charge ratio """ validate.positive_number(mass) return self.lower <= mass < self.upper
[docs]class RangeCollection: """ Operations on multiple ranges """ def __init__(self, ranges: Sequence[Range] = ()): """ Maintain and operate on a collection of ranges that describe the peaks in a mass spectrum. This is the principle class used for mass spectrum range definitions. A collection may be created by manually supplying the Range objects through the constructor, or 1 by 1 through :meth:`RangeCollection.add`. A :class:`RangeCollection` may also be created using the alternate constructors :meth:`RangeCollection.from_rng` and :meth:`RangeCollection.from_rrng` to import the ranges from the two common range file types. A :class:`RangeCollection` can be created as: >>> rng_lst = [Range("Cu", (62.5, 63.5)), Range("Cu", (63.5, 66))] >>> rngs = RangeCollection(rng_list) Or 1 by 1 as: >>> rngs = RangeCollection() >>> rngs.add(Range("Cu", (62.5, 63.5))) >>> rngs.add(Range("Cu", (63.5, 66))) :param ranges: sequence of Range objects """ if not all(isinstance(i, Range) for i in ranges): raise TypeError("Cannot create RangeCollection from non-Range objects") self._ranges = list(ranges) self.__index = 0 self._filepath = "" def __iter__(self): self.__index = 0 return self def __next__(self) -> Range: if len(self._ranges) == 0: raise StopIteration elif self.__index == len(self._ranges): self.__index = 0 raise StopIteration else: self.__index += 1 return self._ranges[self.__index - 1] def __len__(self) -> int: return len(self._ranges) def __repr__(self): retn = "RangeCollection\n" retn += f"Number of ranges: {len(self)}\n" ranges = self.sorted_ranges() if len(self) > 0: min, max = ranges[0].lower, ranges[-1].upper else: min = "" max = "" retn += f"Mass range: {min} - {max}\n" retn += f"Number of unique elements: {len(self.elements())}\n" retn += f"Elements: {', '.join(elem.symbol for elem in self.elements())}\n\n" data = [(i.hill_formula, i.lower, i.upper, i.vol, [round(j, 2) for j in i.color]) for i in self.sorted_ranges()] head = ("Composition", "Min (Da)", "Max (Da)", "Volume", "Color (RGB 0-1)") table = tabulate(data, headers=head) retn += table return retn @property def filepath(self) -> str: """ Get the file path the :class:`RangeCollection` was created from, if it was imported from a file """ return self._filepath @property def ranges(self) -> List[Range]: """ Get a copy of the ranges in the RangeCollection. This returns a copy to prevent accidental modification of the underlying ranges possibly resulting in overlapping ranges. Instead, remove the old range with RangeCollection.remove_by_mass() and add the new one, or use RangeCollection.replace() """ return copy.deepcopy(self._ranges)
[docs] @classmethod def from_rrng(cls, fpath: str): """ Build RangeCollection from \*.rrng files :param fpath: filepath """ retn = cls() retn._filepath = validate.file_exists(fpath) log.info("Reading RRNG file: {}".format(fpath)) conf = ConfigParser() conf.read(fpath) nions = int(conf["Ions"]["Number"]) nranges = int(conf["Ranges"]["number"]) elems = [conf["Ions"]["ion" + str(i)] for i in range(1, nions + 1)] for i in range(1, nranges + 1): line = conf["Ranges"]["Range" + str(i)].split() # IVAS saves unknown elements with a name field and not composition, skip these if any("Name" in i for i in line): continue rmin = float(line.pop(0)) rmax = float(line.pop(0)) # The rest can be converted to a dictionary easily vars = OrderedDict([item.split(":") for item in line]) vol = float(vars.pop("Vol")) col = helpers.hex2rgbF(vars.pop("Color")) # Now the rest should be ions assert all(i in elems for i in vars.keys()) # vars = OrderedDict([(i, int(j)) for i, j in vars.items()]) comp_str = "".join(i + str(j) for i, j in vars.items()) retn.add(Range(comp_str, (rmin, rmax), vol, col)) return retn
[docs] @classmethod def from_rng(cls, filepath: str): """ Build RangeCollection from a .rng file :param filepath: filepath """ raise NotImplementedError()
[docs] def clear(self): """ Remove all Ranges from the RangeCollection """ self._ranges = []
[docs] def add(self, new: Range): """ Add a new :class:`Range` to the :class:`RangeCollection` :param new: the new :class:`Range` :return: """ if not isinstance(new, Range): raise TypeError(f"Can only add Range types to RangeCollection not {type(new)}") else: for r in self.ranges: if r.intersects(new): raise ValueError("Mass ranges cannot coincide") self._ranges.append(new) return new
[docs] def remove_by_mass(self, mass: float): """ Remove a range overlapping the given mass ratio """ validate.positive_number(mass) for i in self._ranges: if i.lower <= mass < i.upper: self._ranges.remove(i)
[docs] def replace(self, old_rng: Range, new_rng: Range): """ Replace an existing Range with a new one. Throws an error if the range is not found. :param old_rng: Range to be replaced :param new_rng: New range """ for i, rng in enumerate(self._ranges): if rng == old_rng: self._ranges[i] = new_rng return raise IndexError(f"RangeCollection does not contain {old_rng}")
[docs] def ions(self) -> Tuple["ap.Ion", ...]: """ Get a tuple of all ions """ return tuple(set([i.ion for i in self.ranges]))
[docs] def elements(self) -> Tuple[Element]: """ Get a tuple of all elements """ allelems = [] for rng in self: elems = [i for i in rng.ion.elements] allelems += elems return tuple(set(allelems))
[docs] def sorted_ranges(self) -> list: """ Get the list of range objects sorted in ascending mass range """ return sorted(self._ranges, key=lambda x: x.lower)
[docs] def check_overlap(self) -> Union[Tuple, Tuple[float, float]]: """ Check if any ranges in the RangeCollection overlap. This returns the first overlap found, not all overlaps. This is provided if Ranges are being directly accessed and modified """ for i, r1 in enumerate(self.ranges): for j, r2 in enumerate(self.ranges): if j <= i: continue else: if r1.intersects(r2): return r1, r2 return ()
[docs] def find_by_mass(self, mass: float) -> Range: """ Get the range that contains the given m/q """ retn = None for range in self.ranges: if mass in range: retn = range if retn is not None: return retn else: raise ValueError(f"No range containing {mass} exists")