# Source code for gemseo.uncertainty.statistics.tolerance_interval.distribution

# Copyright 2021 IRT Saint Exupéry, https://www.irt-saintexupery.com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
#
# 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser 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.
# Contributors:
#    INITIAL AUTHORS - initial API and implementation and/or initial
#                           documentation
#        :author: Matthias De Lozzo
#    OTHER AUTHORS   - MACROSCOPIC CHANGES
"""Computation of tolerance intervals from a data-fitted probability distribution."""

from __future__ import annotations

import logging
from abc import abstractmethod
from typing import TYPE_CHECKING
from typing import Any
from typing import NamedTuple

from numpy import array
from numpy import inf
from strenum import LowercaseStrEnum

from gemseo.core.base_factory import BaseFactory

if TYPE_CHECKING:
from numpy.typing import NDArray

LOGGER = logging.getLogger(__name__)

[docs]
"""Computation of tolerance intervals from a data-fitted probability distribution.

A :class:.ToleranceInterval (TI) is initialized
from the number of samples
used to estimate the parameters of the probability distribution
and from the estimations of these parameters.

A :class:.ToleranceInterval can be evaluated from:

- a coverage defining the minimum percentage of belonging to the TI, e.g. 0.90,
- a level of confidence in [0,1], e.g. 0.95,
- a type of interval,
either 'lower' for lower-sided TI,
'upper' for upper-sided TI
or 'both' for both-sided TI.

.. note::

Lower-sided tolerance intervals are used to analyse the strength of materials.
They are also known as *basis tolerance limits*.
In particular,
the *B-value* is the lower bound of the lower-sided tolerance interval
with 90%-coverage and 95%-confidence
while the *A-value* is the lower bound of the lower-sided tolerance interval
with 95%-coverage and 95%-confidence.
"""

[docs]
class ToleranceIntervalSide(LowercaseStrEnum):
"""The side of the tolerance interval."""

LOWER = "lower"
UPPER = "upper"
BOTH = "both"

[docs]
class Bounds(NamedTuple):
"""The component-wise bounds of a vector."""

lower: NDArray[float]
upper: NDArray[float]

def __init__(
self,
size: int,
) -> None:
"""
Args:
size: The number of samples.
"""  # noqa: D205 D212 D415
self.__size = size

@abstractmethod
def _compute_lower_bound(
self,
coverage: float,
alpha: float,
size: int,
) -> float:
"""Compute the lower bound of the tolerance interval.

Args:
coverage: A minimum percentage of belonging to the TI.
alpha: 1-alpha is the level of confidence in [0,1].
size: The number of samples.

Returns:
The lower bound of the tolerance interval.
"""

@abstractmethod
def _compute_upper_bound(
self,
coverage: float,
alpha: float,
size: int,
) -> float:
"""Compute the upper bound of the tolerance interval.

Args:
coverage: A minimum percentage of belonging to the TI.
alpha: 1-alpha is the level of confidence in [0,1].
size: The number of samples.

Returns:
The upper bound of the tolerance interval.
"""

def _compute_bounds(
self,
coverage: float,
alpha: float,
size: int,
) -> tuple[float, float]:
"""Compute the lower and upper bounds of a both-sided tolerance interval.

Args:
coverage: A minimum percentage of belonging to the TI.
alpha: 1-alpha is the level of confidence in [0,1].
size: The number of samples.

Returns:
The lower and upper bounds of the both-sided tolerance interval.
"""
coverage = (coverage + 1.0) / 2.0
alpha /= 2.0
return (
self._compute_lower_bound(coverage, alpha, size),
self._compute_upper_bound(coverage, alpha, size),
)

def _compute(
self,
coverage: float,
alpha: float,
size: int,
side: ToleranceIntervalSide,
) -> Bounds:
r"""Compute the bounds of the tolerance interval.

Args:
coverage: A minimum percentage of belonging to the TI.
alpha: 1-alpha is the level of confidence in [0,1].
size: The number of samples.
side: The type of the tolerance interval
characterized by its *sides* of interest,
either a lower-sided tolerance interval :math:[a, +\infty[,
an upper-sided tolerance interval :math:]-\infty, b],
or a two-sided tolerance interval :math:[c, d].

Returns:
The bounds of the tolerance interval.

Raises:
ValueError: If the type of tolerance interval is incorrect.
"""
if side == self.ToleranceIntervalSide.LOWER:
lower = self._compute_lower_bound(coverage, alpha, size)
upper = inf
elif side == self.ToleranceIntervalSide.UPPER:
lower = -inf
upper = self._compute_upper_bound(coverage, alpha, size)
elif side == self.ToleranceIntervalSide.BOTH:
lower, upper = self._compute_bounds(coverage, alpha, size)
else:
msg = "The type of tolerance interval is incorrect."
raise ValueError(msg)
return self.Bounds(array([lower]), array([upper]))

[docs]
def compute(
self,
coverage: float,
confidence: float = 0.95,
side: ToleranceIntervalSide = ToleranceIntervalSide.BOTH,
) -> Bounds:
r"""Compute a tolerance interval.

Args:
coverage: A minimum percentage of belonging to the TI.
confidence: A level of confidence in [0,1].
side: The type of the tolerance interval
characterized by its *sides* of interest,
either a lower-sided tolerance interval :math:[a, +\infty[,
an upper-sided tolerance interval :math:]-\infty, b],
or a two-sided tolerance interval :math:[c, d].

Returns:
The tolerance bounds.
"""
return self._compute(coverage, 1 - confidence, self.__size, side)

[docs]
class ToleranceIntervalFactory(BaseFactory):
"""A factory of :class:.ToleranceInterval."""

_CLASS = ToleranceInterval
_MODULE_NAMES = ("gemseo.uncertainty.statistics.tolerance_interval",)

[docs]
def create(
self,
class_name: str,
size: int,
*args: float,
) -> ToleranceInterval:
"""Return an instance of :class:.ToleranceInterval.

Args:
size: The number of samples
used to estimate the parameters of the probability distribution.
*args: The arguments of the probability distribution.

Returns:
The instance of the class.

Raises:
TypeError: If the class cannot be instantiated.
"""
cls = self.get_class(class_name)
try:
return cls(size, *args)
except TypeError:
LOGGER.exception(
"Failed to create class %s with arguments %s", class_name, args
)
msg = f"Cannot create {class_name}ToleranceInterval with arguments {args}"
raise RuntimeError(msg) from None

[docs]
def get_class(self, name: str) -> type[Any]:
"""Return a class from its name.

Args:
name: The name of the class.

Returns:
The class.
"""
return super().get_class(f"{name}ToleranceInterval")