Revision 0db9189321820754c04305cb8e87cf759e9038af authored by Duncan Macleod on 10 May 2021, 08:07:17 UTC, committed by GitHub on 10 May 2021, 08:07:17 UTC
1 parent 18f300e
Raw File
boundaries.py
# Copyright (C) 2016  Collin Capano
# This program 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 3 of the License, or (at your
# option) any later version.
#
# This program 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 this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.


#
# =============================================================================
#
#                                   Preamble
#
# =============================================================================
#
"""
This modules provides utilities for manipulating parameter boundaries. Namely,
classes are offered that will map values to a specified domain using either
cyclic boundaries or reflected boundaries.
"""

import numpy

class _Bound(float):
    """Adds methods to float for boundary comparisons."""

    name = None

    def larger(self, other):
        """A function to determine whether or not `other` is larger
        than the bound. This raises a NotImplementedError; classes that
        inherit from this must define it.
        """
        raise NotImplementedError("larger function not set")

    def smaller(self, other):
        """A function to determine whether or not `other` is smaller
        than the bound. This raises a NotImplementedError; classes that
        inherit from this must define it.
        """
        raise NotImplementedError("smaller function not set")


class OpenBound(_Bound):
    """Sets larger and smaller functions to be `>` and `<`, respectively."""

    name = 'open'

    def larger(self, other):
        """Returns True if `other` is `>`, False otherwise"""
        return self > other

    def smaller(self, other):
        """Returns True if `other` is `<`, False otherwise."""
        return self < other


class ClosedBound(_Bound):
    """Sets larger and smaller functions to be `>=` and `<=`, respectively."""

    name = 'closed'

    def larger(self, other):
        return self >= other

    def smaller(self, other):
        return self <= other


class ReflectedBound(ClosedBound):
    """Inherits from `ClosedBound`, adding reflection functions."""

    name = 'reflected'

    def reflect(self, value):
        return 2*self - value

    def reflect_left(self, value):
        """Only reflects the value if is > self."""
        if value > self:
            value = self.reflect(value)
        return value

    def reflect_right(self, value):
        """Only reflects the value if is < self."""
        if value < self:
            value = self.reflect(value)
        return value


boundary_types = {
    OpenBound.name: OpenBound,
    ClosedBound.name: ClosedBound,
    ReflectedBound.name: ReflectedBound
}


#
#   Helper functions for applying conditions to boundaries
#

def apply_cyclic(value, bounds):
    """Given a value, applies cyclic boundary conditions between the minimum
    and maximum bounds.

    Parameters
    ----------
    value : float
        The value to apply the cyclic conditions to.
    bounds : Bounds instance
        Boundaries to use for applying cyclic conditions.

    Returns
    -------
    float
        The value after the cyclic bounds are applied.
    """
    return (value - bounds._min) %(bounds._max - bounds._min) + bounds._min

def reflect_well(value, bounds):
    """Given some boundaries, reflects the value until it falls within both
    boundaries. This is done iteratively, reflecting left off of the
    `boundaries.max`, then right off of the `boundaries.min`, etc.

    Parameters
    ----------
    value : float
        The value to apply the reflected boundaries to.
    bounds : Bounds instance
        Boundaries to reflect between. Both `bounds.min` and `bounds.max` must
        be instances of `ReflectedBound`, otherwise an AttributeError is
        raised.

    Returns
    -------
    float
        The value after being reflected between the two bounds.
    """
    while value not in bounds:
        value = bounds._max.reflect_left(value)
        value = bounds._min.reflect_right(value)
    return value


def _pass(value):
    """Just return the given value."""
    return value


#
#   Bounds class
#

class Bounds(object):
    """Creates and stores bounds using the given values.

    The type of boundaries used can be set using the `btype_(min|max)`
    parameters. These arguments set what kind of boundary is used at the
    minimum and maximum bounds. Specifically, if `btype_min` (`btype_max`) is
    set to:

     * "open": the minimum (maximum) boundary will be an instance of
       `OpenBound`. This means that a value must be `>` (`<`) the bound
       for it to be considered within the bounds.
     * "closed": the minimum (maximum) boundary will be an instance of
       `ClosedBound`. This means that a value must be `>=` (`<=`) the bound
       for it to be considered within the bounds.
     * "reflected": the minimum (maximum) boundary will be an isntance of
       `ReflectedBound`. This means that a value will be reflected to the
       right (left) if `apply_conditions` is used on the value. For more
       details see `apply_conditions`.

    If the `cyclic` keyword is set to True, then `apply_conditions` will cause
    values to be wrapped around to the minimum (maximum) bound if the value
    is > (<=) the maximum (minimum) bound. For more details see
    `apply_conditions`.

    Values can be checked whether or not they occur within the bounds using
    `in`; e.g., `6 in bounds`. This is done without applying any boundary
    conditions. To apply conditions, then check whether the value is in
    bounds, use the `contains_conditioned` method.

    The default is for the minimum bound to be "closed" and the maximum bound
    to be "open", i.e., a right-open interval.

    Parameters
    ----------
    min_bound : {-numpy.inf, float}
        The value of the lower bound. Default is `-inf`.
    max_bound : {numpy.inf, float}
        The value of the upper bound. Default is `inf`.
    btype_min : {'closed', string}
        The type of the lower bound; options are "closed", "open", or
        "reflected". Default is "closed".
    btype_min : {'open', string}
        The type of the lower bound; options are "closed", "open", or
        "reflected". Default is "open".
    cyclic : {False, bool}
        Whether or not to make the bounds cyclic; default is False. If True,
        both the minimum and maximum bounds must be finite.

    Attributes
    ----------
    min : _Bound instance
        The minimum bound.
    max : _Bound instance
        The maximum bound.
    cyclic : bool
        Whether the bounds are cyclic or not.

    Examples
    --------
    Create a right-open interval between -1 and 1 and test whether various
    values are within them:
    >>> bounds = Bounds(-1., 1.)
    >>> -1 in bounds
    True
    >>> 0 in bounds
    True
    >>> 1 in bounds
    False

    Create an open interval between -1 and 1 and test the same values:
    >>> bounds = Bounds(-1, 1, btype_min="open")
    >>> -1 in bounds
    False
    >>> 0 in bounds
    True
    >>> 1 in bounds
    False

    Create cyclic bounds between -1 and 1 and plot the effect of conditioning
    on points between -10 and 10:
    >>> bounds = Bounds(-1, 1, cyclic=True)
    >>> x = numpy.linspace(-10, 10, num=1000)
    >>> conditioned_x = bounds.apply_conditions(x)
    >>> fig = pyplot.figure()
    >>> ax = fig.add_subplot(111)
    >>> ax.plot(x, x, c='b', lw=2, label='input')
    >>> ax.plot(conditioned_x, x, c='r', lw=1)
    >>> ax.vlines([-1., 1.], x.min(), x.max(), color='k', linestyle='--')
    >>> ax.set_title('cyclic bounds between x=-1,1')
    >>> fig.show()

    Create a reflected bound at -1 and plot the effect of conditioning:
    >>> bounds = Bounds(-1, 1, btype_min='reflected')
    >>> x = numpy.linspace(-10, 10, num=1000)
    >>> conditioned_x = bounds.apply_conditions(x)
    >>> fig = pyplot.figure()
    >>> ax = fig.add_subplot(111)
    >>> ax.plot(x, x, c='b', lw=2, label='input')
    >>> ax.plot(conditioned_x, x, c='r', lw=1)
    >>> ax.vlines([-1., 1.], x.min(), x.max(), color='k', linestyle='--')
    >>> ax.set_title('reflected right at x=-1')
    >>> fig.show()

    Create a reflected bound at 1 and plot the effect of conditioning:
    >>> bounds = Bounds(-1, 1, btype_max='reflected')
    >>> x = numpy.linspace(-10, 10, num=1000)
    >>> conditioned_x = bounds.apply_conditions(x)
    >>> fig = pyplot.figure()
    >>> ax = fig.add_subplot(111)
    >>> ax.plot(x, x, c='b', lw=2, label='input')
    >>> ax.plot(conditioned_x, x, c='r', lw=1)
    >>> ax.vlines([-1., 1.], x.min(), x.max(), color='k', linestyle='--')
    >>> ax.set_title('reflected left at x=1')
    >>> fig.show()

    Create reflected bounds at -1 and 1 and plot the effect of conditioning:
    >>> bounds = Bounds(-1, 1, btype_min='reflected', btype_max='reflected')
    >>> x = numpy.linspace(-10, 10, num=1000)
    >>> conditioned_x = bounds.apply_conditions(x)
    >>> fig = pyplot.figure()
    >>> ax = fig.add_subplot(111)
    >>> ax.plot(x, x, c='b', lw=2, label='input')
    >>> ax.plot(conditioned_x, x, c='r', lw=1)
    >>> ax.vlines([-1., 1.], x.min(), x.max(), color='k', linestyle='--')
    >>> ax.set_title('reflected betewen x=-1,1')
    >>> fig.show()
    """

    def __init__(self, min_bound=-numpy.inf, max_bound=numpy.inf,
            btype_min='closed', btype_max='open', cyclic=False):
        # check boundary values
        if min_bound >= max_bound:
            raise ValueError("min_bound must be < max_bound")
        if cyclic and not (
                numpy.isfinite(min_bound) and numpy.isfinite(max_bound)):
            raise ValueError("if using cyclic, min and max bounds must both "
                "be finite")
        # store bounds
        try:
            self._min = boundary_types[btype_min](min_bound)
        except KeyError:
            raise ValueError("unrecognized btype_min {}".format(btype_min))
        try:
            self._max = boundary_types[btype_max](max_bound)
        except KeyError:
            raise ValueError("unrecognized btype_max {}".format(btype_max))
        # store cyclic conditions
        self._cyclic = bool(cyclic)
        # store reflection conditions; we'll vectorize them here so that they
        # can be used with arrays
        if self._min.name == 'reflected' and self._max.name == 'reflected':
            self._reflect = numpy.vectorize(self._reflect_well)
            self.reflected = 'well'
        elif self._min.name == 'reflected':
            self._reflect = numpy.vectorize(self._min.reflect_right)
            self.reflected = 'min'
        elif self._max.name == 'reflected':
            self._reflect = numpy.vectorize(self._max.reflect_left)
            self.reflected = 'max'
        else:
            self._reflect = _pass
            self.reflected = False

    def __repr__(self):
        return str(self.__class__)[:-1] + " " + " ".join(
                   map(str, ["min", self._min, "max", self._max,
                             "cyclic", self._cyclic])) + ">"

    @property
    def min(self):
        return self._min

    @property
    def max(self):
        return self._max

    @property
    def cyclic(self):
        return self._cyclic

    def __getitem__(self, ii):
        if ii == 0:
            return self._min
        elif ii == 1:
            return self._max
        else:
            raise IndexError("index {} out of range".format(ii))

    def __abs__(self):
        return abs(self._max - self._min)

    def __contains__(self, value):
        return self._min.smaller(value) & self._max.larger(value)

    def _reflect_well(self, value):
        """Thin wrapper around `reflect_well` that passes self as the `bounds`.
        """
        return reflect_well(value, self)

    def _apply_cyclic(self, value):
        """Thin wrapper around `apply_cyclic` that passes self as the `bounds`.
        """
        return apply_cyclic(value, self)

    def apply_conditions(self, value):
        """Applies any boundary conditions to the given value.

        The value is manipulated according based on the following conditions:

         * If `self.cyclic` is True then `value` is wrapped around to the
           minimum (maximum) bound if `value` is `>= self.max` (`< self.min`)
           bound. For example, if the minimum and maximum bounds are `0, 2*pi`
           and `value = 5*pi`, then the returned value will be `pi`.
         * If `self.min` is a reflected boundary then `value` will be
           reflected to the right if it is `< self.min`. For example, if
           `self.min = 10` and `value = 3`, then the returned value will be
           17.
         * If `self.max` is a reflected boundary then `value` will be
           reflected to the left if it is `> self.max`. For example, if
           `self.max = 20` and `value = 27`, then the returned value will be
           13.
         * If `self.min` and `self.max` are both reflected boundaries, then
           `value` will be reflected between the two boundaries until it
           falls within the bounds. The first reflection occurs off of the
           maximum boundary. For example, if `self.min = 10`, `self.max =
           20`, and `value = 42`, the returned value will be 18 ( the first
           reflection yields -2, the second 22, and the last 18).
         * If neither bounds are reflected and cyclic is False, then the
           value is just returned as-is.

        Parameters
        ----------
        value : float
            The value to apply the conditions to.

        Returns
        -------
        float
            The value after the conditions are applied; see above for details.
        """
        retval = value
        if self._cyclic:
            retval = apply_cyclic(value, self)
        retval = self._reflect(retval)
        if isinstance(retval, numpy.ndarray) and retval.size == 1:
            try:
                retval = retval[0]
            except IndexError:
                retval = float(retval)
        return retval

    def contains_conditioned(self, value):
        """Runs `apply_conditions` on the given value before testing whether it
        is in bounds. Note that if `cyclic` is True, or both bounds
        are reflected, than this will always return True.

        Parameters
        ----------
        value : float
            The value to test.

        Returns
        -------
        bool
            Whether or not the value is within the bounds after the boundary
            conditions are applied.
        """
        return self.apply_conditions(value) in self
back to top