#
# The high-throughput toolkit (httk)
# Copyright (C) 2012-2015 Rickard Armiento
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Exact-rational vector (tensor) algebra: :class:`FracVector` and :class:`FracScalar`.
"""
import fractions
import itertools
import math
import operator
from collections.abc import Callable
from functools import reduce
from math import gcd as calc_gcd
from typing import Any, ClassVar, Self, cast
from httk.core import exactmath
from httk.core.vectors._nested import (
nested_map_fractions_tuple,
nested_map_list,
nested_map_tuple,
nested_reduce,
nested_reduce_fractions,
nested_reduce_levels,
tuple_eye,
tuple_index,
tuple_random,
tuple_slice,
tuple_zeros,
)
from httk.core.vectors.vector_api import Fractions
from httk.core.vectors.vector_backend import VectorBackend
# The nested nominator structure is recursive: either a bare integer (a scalar) or a
# (possibly nested) tuple of such structures. A single shared integer denominator is
# stored separately in FracVector.denom.
[docs]
type Noms = int | tuple[Noms, ...]
def _noms_equal(a: Any, b: Any) -> bool:
"""
Structural equality of two nested nominator sequences.
``FracVector`` stores nominators as nested tuples while ``MutableFracVector`` stores them as
nested lists, and a nested ``list`` never compares ``==`` to an otherwise-identical nested
``tuple``. This helper compares them tolerant of that difference while keeping the common
same-type case on the fast C-level ``==`` path.
"""
if a == b:
return True
a_seq = isinstance(a, (list, tuple))
b_seq = isinstance(b, (list, tuple))
if a_seq and b_seq and type(a) is not type(b):
if len(a) != len(b):
return False
return all(_noms_equal(x, y) for x, y in zip(a, b))
return False
def _fracvector_to_fractions(fv: "FracVectorBase") -> Fractions:
"""Return a rational vector's exact nested Fraction interchange representation."""
denom = fv.denom
def rec(noms: Any) -> Fractions:
if isinstance(noms, (tuple, list)):
return tuple(rec(n) for n in noms)
return fractions.Fraction(noms, denom)
return rec(fv.noms)
def _copy_noms(noms: Any, container: type[tuple] | type[list]) -> Any:
if type(noms) is tuple or type(noms) is list:
return container(_copy_noms(item, container) for item in noms)
return noms
def _has_int_noms(noms: Any) -> bool:
if type(noms) is int:
return True
if type(noms) is tuple or type(noms) is list:
return all(_has_int_noms(item) for item in noms)
return False
def _int_fraction_noms_and_lcd(values: Any, container: type[tuple] | type[list]) -> tuple[Any, int] | None:
"""Convert an exact int/Fraction tree without constructing per-leaf Fractions."""
def find_lcd(node: Any) -> int | None:
node_type = type(node)
if node_type is int:
return 1
if node_type is fractions.Fraction:
return node.denominator
if node_type is tuple or node_type is list:
lcd = 1
for item in node:
item_lcd = find_lcd(item)
if item_lcd is None:
return None
lcd = lcd * item_lcd // calc_gcd(lcd, item_lcd)
return lcd
return None
lcd = find_lcd(values)
if lcd is None:
return None
def scale(node: Any) -> Any:
node_type = type(node)
if node_type is int:
return node * lcd
if node_type is fractions.Fraction:
return node.numerator * (lcd // node.denominator)
if node_type is tuple or node_type is list:
return container(scale(item) for item in node)
raise TypeError("unreachable")
return scale(values), lcd
[docs]
class FracVectorBase:
"""
Shared implementation for immutable :class:`FracVector` and mutable
:class:`~httk.core.vectors.mutablefracvector.MutableFracVector` N-dimensional exact-rational
tensors.
A FracVector consists of a multidimensional tuple of integer nominators, and a single
shared integer denominator.
Since FracVectors are immutable, every operation on a FracVector returns a new FracVector
with the result of the operation. A created FracVector never changes. Hence, they are safe
to use as keys in dictionaries, to use in sets, etc.
Note: most methods return FracVector results that are not simplified (i.e., the FracVector
returned does *not* have the smallest possible integer denominator). To return a FracVector
with the smallest possible denominator, just call :meth:`simplify` at the last step.
Create a FracVector from various types of sequences.
Simplest use::
FracVector(some_kind_of_sequence)
where ``some_kind_of_sequence`` can be any nested list or tuple of objects that can be used
in the constructor of the Python Fraction class (also works with strings!). If any object
found while traveling the items has a ``.to_fractions()`` method, it will be called and is
expected to return a fraction or list or tuple of fractions.
:param values: A nested sequence of objects accepted by :class:`fractions.Fraction`.
:param denom: An optional additional common denominator for all nominators.
:param simplify: Whether to return a FracVector with the smallest possible denominator.
:param chain: Whether to remove the outermost dimension and chain the sub-sequences. I.e.,
if ``input=[[1, 2, 3], [4, 5, 6]]`` then ``FracVector(input, chain=True)`` gives
``[1, 2, 3, 4, 5, 6]``.
:param min_accuracy: The minimum accuracy assumed in string input. The default is
``1/10000``, i.e. ``0.33 = 0.3300 = 33/100``, whereas ``0.3333 = 1/3``. Set it
to None to assume infinite accuracy, i.e. convert exactly whatever string is
given (unless a standard deviation is given as a parenthesis after the string).
Note: FracVector itself implements ``.to_fractions()``, and hence the same constructor
allows stacking several FracVector objects like this::
vertical_fracvector = FracVector([[fracvector1], [fracvector2]])
horizontal_fracvector = FracVector([fracvector1, fracvector2], chain=True)
"""
#### Static methods to overload in subclasses
# a map-type function that handles nested sequences
[docs]
nested_map: ClassVar[Callable[..., Any]] = staticmethod(nested_map_tuple)
# a map-type function that handles nested sequences and objects that can be converted into fractions
[docs]
nested_map_fractions: ClassVar[Callable[..., Any]] = staticmethod(nested_map_fractions_tuple)
# a method used to copy the nominator sequence
_dup_noms: ClassVar[Callable[..., Any]] = staticmethod(tuple)
_dim: tuple[int, ...] | None
# Class-level default so subclasses that build instances through __new__ without
# running FracVector.__init__ (the view classes do) still have a defined cache slot.
_hash_cache: int | None = None
#### Creation
def __init__(
self,
values: Any,
*,
denom: int | None = None,
simplify: bool = False,
chain: bool = False,
min_accuracy: fractions.Fraction | None = fractions.Fraction(1, 10000),
) -> None:
noms, denominator = self._normalized_noms_and_denom(values, denom=denom, chain=chain, min_accuracy=min_accuracy)
self._assign_raw(noms, denominator)
if simplify and self.denom != 1:
simplified = self.simplify()
self._assign_raw(simplified.noms, simplified.denom)
@classmethod
def _normalized_noms_and_denom(
cls,
values: Any,
*,
denom: int | None,
chain: bool,
min_accuracy: fractions.Fraction | None,
) -> tuple[Any, int]:
"""Return normalized raw data for the converting constructor."""
if type(denom) is int or denom is None:
container = tuple if cls._dup_noms is tuple else list if cls._dup_noms is list else None
if (
container is not None
and isinstance(values, FracVectorBase)
and type(values.denom) is int
and values.denom != 0
and _has_int_noms(values.noms)
):
normalized = values.simplify()
v_noms = _copy_noms(normalized.noms, container)
if chain:
v_noms = cls._dup_noms(itertools.chain(*v_noms))
return v_noms, normalized.denom if denom is None else normalized.denom * denom
if container is not None:
fast = _int_fraction_noms_and_lcd(values, container)
if fast is not None:
v_noms, lcd = fast
if chain:
v_noms = cls._dup_noms(itertools.chain(*v_noms))
return v_noms, lcd if denom is None else lcd * denom
def getlcd(a: Any, y: Any) -> Any:
b = abs(y).denominator
return a * b // calc_gcd(a, b)
def getnumerators(x: Any) -> Any:
return (x * lcd).numerator
fracnoms = cls.nested_map_fractions(lambda x: exactmath.any_to_fraction(x, min_accuracy=min_accuracy), values)
lcd = nested_reduce_fractions(lambda x, y: getlcd(x, y), fracnoms, initializer=1)
v_noms = cls.nested_map_fractions(lambda x: getnumerators(x), fracnoms)
if chain:
v_noms = cls._dup_noms(itertools.chain(*v_noms))
return v_noms, lcd if denom is None else lcd * denom
def _assign_raw(self, noms: Any, denom: int) -> None:
self.noms = noms
self.denom = denom
self._dim = None
self._hash_cache = None
@classmethod
def _of(cls, noms: Noms, denom: int = 1) -> Self:
"""
Build from trusted, already-normalized nested integer tuples.
This raw internal/hot-path constructor performs no validation or conversion.
"""
instance = object.__new__(cls)
instance._assign_raw(noms, denom)
return instance
# Note, these are different, and thus named different (get_ prefix), than the corresponding
# methods in a list, since they do not modify the vector itself.
[docs]
def get_append(self, other: Any) -> Self:
"""Return a new vector with ``other`` appended as one element.
:param other: The element to append.
:return: The extended vector.
"""
return self.__class__([self, [other]], chain=True)
[docs]
def get_extend(self, other: Any) -> Self:
"""Return a new vector with the elements of ``other`` appended.
:param other: The elements to append.
:return: The extended vector.
"""
return self.__class__([self, other], chain=True)
[docs]
def get_insert(self, pos: int, other: Any) -> Self:
"""Return a new vector with ``other`` inserted at ``pos``.
:param pos: The insertion position.
:param other: The element to insert.
:return: The extended vector.
"""
return self.__class__([self[:pos], [other], self[pos:]], chain=True)
[docs]
def get_prepend(self, other: Any) -> Self:
"""Return a new vector with ``other`` prepended as one element.
:param other: The element to prepend.
:return: The extended vector.
"""
return self.__class__([[other], self], chain=True)
[docs]
def get_prextend(self, other: Any) -> Self:
"""Return a new vector with the elements of ``other`` prepended.
:param other: The elements to prepend.
:return: The extended vector.
"""
return self.__class__([other, self], chain=True)
[docs]
def get_stacked(self, other: Any) -> Self:
"""
Return a new FracVector with ``other`` stacked after ``self`` along a new leading axis.
``self`` and ``other`` must have the same shape; the result gains one extra outermost
dimension of size two (numpy ``stack``-like). E.g. stacking the row ``[1, 2, 3]`` with
``[4, 5, 6]`` gives ``[[1, 2, 3], [4, 5, 6]]``.
:param other: A vector with the same shape as ``self``.
:return: The stacked vector.
"""
return self.__class__([self, other])
[docs]
def get_prestacked(self, other: Any) -> Self:
"""
Return a new FracVector with ``other`` stacked before ``self`` along a new leading axis.
The mirror of :meth:`get_stacked`: stacking ``[1, 2, 3]`` in front with ``[4, 5, 6]``
gives ``[[4, 5, 6], [1, 2, 3]]``.
:param other: A vector with the same shape as ``self``.
:return: The prestacked vector.
"""
return self.__class__([other, self])
[docs]
def get_stackedinsert(self, pos: int, other: Any) -> Self:
"""Return a new vector with ``other`` inserted at ``pos`` along the flattened axis.
:param pos: The insertion position.
:param other: The element to insert.
:return: The extended vector.
"""
return self.__class__([self[:pos], [other], self[pos:]], chain=True)
@classmethod
[docs]
def chain_vecs(cls, vecs: Any) -> Self:
"""
Optimized chaining of FracVectors.
:param vecs: FracVectors that all share the same denominator.
:return: The same thing as ``FracVector(vecs, chain=True)``, i.e., removes the
outermost dimension and chains the sub-sequences. If ``input=[[1, 2, 3], [4, 5, 6]]``
then it gives ``[1, 2, 3, 4, 5, 6]``, but this method assumes all vectors share the
same denominator (it raises an exception if this is not true).
"""
noms: list[Any] = []
denom = vecs[0].denom
for vec in vecs:
if vec.denom != denom:
raise Exception("FracVector.merge: can only work with vectors sharing the same denom.")
noms += vec.noms
return cls._of(cls._dup_noms(noms), denom)
@classmethod
[docs]
def stack_vecs(cls, vecs: Any) -> Self:
"""
Optimized stacking of FracVectors.
:param vecs: FracVectors that all share the same denominator.
:return: The same thing as ``FracVector(vecs)``, but only works if all vectors
share the same denominator (raises an exception if this is not true).
"""
noms: list[Any] = []
denom = vecs[0].denom
for vec in vecs:
if vec.denom != denom:
raise Exception("FracVector.stack: can only work with vectors sharing the same denom.")
noms += [vec.noms]
return cls._of(cls._dup_noms(noms), denom)
@classmethod
[docs]
def eye(cls, dims: tuple[int, ...]) -> Self:
"""
Create a diagonal one-matrix with the given dimensions.
:param dims: The shape of the diagonal tensor.
:return: The diagonal one-matrix.
"""
return cls(tuple_eye(dims))
@classmethod
[docs]
def zeros(cls, dims: tuple[int, ...]) -> Self:
"""
Create a zero matrix with the given dimensions.
:param dims: The shape of the zero tensor.
:return: The zero matrix.
"""
return cls(tuple_zeros(dims))
@classmethod
[docs]
def random(
cls,
dims: tuple[int, ...],
minnom: int = -100,
maxnom: int = 100,
denom: int = 100,
) -> Self:
"""
Create a matrix with the given dimensions filled with random rational numbers.
:param dims: The shape of the generated matrix.
:param minnom: The inclusive lower bound for generated nominators.
:param maxnom: The inclusive upper bound for generated nominators.
:param denom: The shared denominator for generated values.
:return: The generated matrix.
"""
return cls(tuple_random(dims, minval=minnom, maxval=maxnom), denom=denom)
@classmethod
[docs]
def from_tuple(cls, t: tuple[int, Noms]) -> Self:
"""
Return a FracVector created from the tuple representation ``(denom, noms)``, as returned
by the :meth:`to_tuple` method. ``from_tuple(v.to_tuple())`` reconstructs ``v`` exactly.
:param t: The ``(denom, noms)`` representation to reconstruct.
:return: The reconstructed FracVector.
"""
return cls._of(t[1], t[0])
@classmethod
def _create_func(
cls,
data: Any,
func: Callable[..., Any],
find_best_rational: bool = True,
**args: Any,
) -> Self:
def apply_func(arg: Any) -> Any:
if isinstance(arg, str):
if find_best_rational:
val, delta = exactmath.string_to_val_and_delta(arg)
low = val - delta
high = val + delta
lowval = func(low, **args)
highval = func(high, **args)
return exactmath.best_rational_in_interval(lowval, highval)
else:
val, delta = exactmath.string_to_val_and_delta(arg)
if "prec" in args:
low = val - fractions.Fraction(args["prec"]) * 10
high = val + fractions.Fraction(args["prec"]) * 10
else:
low = val - fractions.Fraction(1, 100000000000)
high = val + fractions.Fraction(1, 100000000000)
lowval = func(low, **args)
highval = func(high, **args)
return exactmath.best_rational_in_interval(lowval, highval)
else:
try:
return func(arg.to_fraction())
except Exception:
return func(fractions.Fraction(arg))
newdata = nested_map_tuple(apply_func, data)
return cls(newdata)
@classmethod
[docs]
def from_cos(
cls,
data: Any,
degrees: bool = False,
limit: bool = False,
find_best_rational: bool = True,
prec: fractions.Fraction = fractions.Fraction(1, 1000000),
) -> Self:
"""
Create a FracVector as the cosine of the argument ``data``. If ``data`` is composed of
strings, the standard deviation of the numbers is taken into account, and the best
possible fractional approximation to the cosines of the data is returned within the
standard deviation.
This is not the same as ``FracVector(data).cos()``, which creates the best
possible fractional approximations of ``data`` and then takes cos on that.
:param data: Values to transform elementwise.
:param degrees: Whether to interpret values in degrees.
:param limit: Whether to bound the resulting denominator by the precision.
:param find_best_rational: Whether to choose the best rational within each input interval.
:param prec: The requested approximation precision.
:return: The elementwise cosine vector.
"""
return cls._create_func(
data,
exactmath.cos,
find_best_rational=find_best_rational,
degrees=degrees,
limit=limit,
prec=prec,
)
@classmethod
[docs]
def from_sin(
cls,
data: Any,
degrees: bool = False,
limit: bool = False,
prec: fractions.Fraction = fractions.Fraction(1, 1000000),
) -> Self:
"""
Create a FracVector as the sine of the argument ``data``. If ``data`` is composed of
strings, the standard deviation of the numbers is taken into account, and the best
possible fractional approximation to the sines of the data is returned within the
standard deviation.
This is not the same as ``FracVector(data).sin()``, which creates the best
possible fractional approximations of ``data`` and then takes sin on that.
:param data: Values to transform elementwise.
:param degrees: Whether to interpret values in degrees.
:param limit: Whether to bound the resulting denominator by the precision.
:param prec: The requested approximation precision.
:return: The elementwise sine vector.
"""
return cls._create_func(data, exactmath.sin, degrees=degrees, limit=limit, prec=prec)
@classmethod
[docs]
def from_exp(
cls,
data: Any,
prec: fractions.Fraction = fractions.Fraction(1, 1000000),
limit: bool = False,
) -> Self:
"""
Create a FracVector as the exponent of the argument ``data``. If ``data`` is composed of
strings, the standard deviation of the numbers is taken into account, and the best
possible fractional approximation to the exponents of the data is returned within the
standard deviation.
This is not the same as ``FracVector(data).exp()``, which creates the best
possible fractional approximations of ``data`` and then takes exp on that.
:param data: Values to transform elementwise.
:param prec: The requested approximation precision.
:param limit: Whether to bound the resulting denominator by the precision.
:return: The elementwise exponential vector.
"""
return cls._create_func(data, exactmath.exp, limit=limit, prec=prec)
@classmethod
[docs]
def pi(
cls,
prec: fractions.Fraction = fractions.Fraction(1, 1000000),
limit: bool = False,
) -> Self:
"""
Create a scalar FracVector with a rational approximation of pi to precision ``prec``.
:param prec: The requested approximation precision.
:param limit: Whether to bound the denominator by the precision.
:return: A scalar rational approximation of pi.
"""
return cls(exactmath.pi(prec, limit=limit))
#### Properties
@property
[docs]
def dim(self) -> tuple[int, ...]:
"""
A tuple with the dimensionality of each dimension of the FracVector (the noms are
assumed to be a nested list of rectangular shape).
"""
if self._dim is None:
dimchk: Any = self.noms
dims: list[int] = []
while isinstance(dimchk, (list, tuple)) and dimchk:
dims.append(len(dimchk))
dimchk = dimchk[0]
self._dim = tuple(dims)
return self._dim
@property
[docs]
def nom(self) -> int:
"""
Return the integer nominator of a scalar FracVector.
"""
if self.dim != ():
raise Exception("FracVector.nom: attempt to access scalar nominator on non-scalar FracVector:" + str(self))
return cast(int, self.noms)
#### Methods
[docs]
def validate(self) -> bool:
"""Return whether the vector's stored structure is valid."""
# TODO: check all dimensions and make sure noms is a square tensor of only tuples
return True
[docs]
def to_tuple(self) -> tuple[int, Noms]:
"""
Return the FracVector on tuple representation ``(denom, ...noms...)``.
:return: The denominator and nested nominators.
"""
return (self.denom, self.noms)
[docs]
def to_floats(self) -> Any:
"""
Convert the FracVector to a (nested) list of floats.
:return: The values converted to floats.
"""
def to_floats_nan_check(x: Any, denom: int) -> float:
# A nominator is normally an exact (arbitrary-precision) int; guard the NaN
# test with an isinstance check so math.isnan() never tries to convert a very
# large exact integer to a float first (which would overflow).
if isinstance(x, float):
return x if math.isnan(x) else x / denom
return float(fractions.Fraction(x, denom))
return nested_map_list(lambda x: to_floats_nan_check(x, self.denom), self.noms)
[docs]
def to_float(self) -> float:
"""
Convert a scalar FracVector to a single float.
:return: The scalar value as a float.
"""
return float(fractions.Fraction(self.nom, self.denom))
[docs]
def to_fractions(self) -> Any:
"""
Convert the FracVector to a (nested) list of fractions.
:return: The values converted to :class:`fractions.Fraction` instances.
"""
return nested_map_list(lambda x: fractions.Fraction(x, self.denom), self.noms)
[docs]
def to_fraction(self) -> fractions.Fraction:
"""
Convert a scalar FracVector to a fraction.
:return: The scalar value as a fraction.
"""
return fractions.Fraction(self.nom, self.denom)
[docs]
def flatten(self) -> Self:
"""
Return a FracVector that has been flattened out to a single row vector.
:return: The flattened vector.
"""
noms = nested_reduce(lambda x, y: x + [y], self.noms, initializer=[])
return self.__class__._of(self._dup_noms(noms), self.denom)
@classmethod
[docs]
def set_common_denom(cls, A: Any, B: Any) -> tuple[Self, Self, int]:
"""
Used internally to combine two different FracVectors.
Returns a tuple ``(A2, B2, denom)`` where A2 is numerically equal to A, and B2 is
numerically equal to B, but A2 and B2 are both set on the same shared denominator
``denom``, which is the *product* of the denominators of A and B.
:param A: The first vector or value.
:param B: The second vector or value.
:return: The converted first vector, second vector, and shared denominator.
"""
if not isinstance(A, FracVectorBase):
A = cls._of(A, 1)
if not isinstance(B, FracVectorBase):
B = cls._of(B, 1)
denom = A.denom * B.denom
mA = B.denom
mB = A.denom
Anoms = A._map_over_noms(lambda x: x * mA)
Bnoms = B._map_over_noms(lambda x: x * mB)
return cls._of(Anoms, denom), cls._of(Bnoms, denom), denom
[docs]
def sign(self) -> int:
"""
Return the sign of the scalar FracVector: -1, 0 or 1.
:return: ``-1``, ``0``, or ``1`` according to the scalar sign.
"""
if self.dim != ():
raise Exception("FracVector.sign: attempt to access scalar nominator on non-scalar FracVector.")
if cast(int, self.noms) < 0:
return -1
elif cast(int, self.noms) > 0:
return 1
else:
return 0
[docs]
def T(self) -> Self:
"""
Return the transpose, ``A^T``.
:return: The transposed vector or matrix.
"""
dim = self.dim
A = cast(Any, self.noms)
if len(dim) == 0:
return self.__class__._of(self.noms, self.denom)
elif len(dim) == 1:
noms = self._dup_noms((A[col],) for col in range(dim[0]))
return self.__class__._of(noms, self.denom)
elif len(dim) == 2:
noms = self._dup_noms(self._dup_noms(A[col][row] for col in range(dim[0])) for row in range(dim[1]))
return self.__class__._of(noms, self.denom)
raise Exception("FracVector.T(): on non 1 or 2 dimensional object not implemented")
[docs]
def det(self) -> Self:
"""
Return the determinant of the FracVector as a scalar FracVector.
:return: The determinant.
"""
dim = self.dim
if dim == (3, 3):
A = cast(Any, self.noms)
noms = (
A[0][0] * A[1][1] * A[2][2]
+ A[0][1] * A[1][2] * A[2][0]
+ A[0][2] * A[1][0] * A[2][1]
- A[0][2] * A[1][1] * A[2][0]
- A[0][1] * A[1][0] * A[2][2]
- A[0][0] * A[1][2] * A[2][1]
)
return self.__class__._of(noms, self.denom**3)
elif dim == (4, 4):
A = cast(Any, self.noms)
noms = (
A[0][0] * A[1][1] * A[2][2] * A[3][3]
+ A[0][0] * A[2][1] * A[3][2] * A[1][3]
+ A[0][0] * A[3][1] * A[1][2] * A[2][3]
+ A[1][0] * A[0][1] * A[3][2] * A[2][3]
+ A[1][0] * A[2][1] * A[0][2] * A[3][3]
+ A[1][0] * A[3][1] * A[2][2] * A[0][3]
+ A[2][0] * A[0][1] * A[1][2] * A[3][3]
+ A[2][0] * A[1][1] * A[3][2] * A[0][3]
+ A[2][0] * A[3][1] * A[0][2] * A[1][3]
+ A[3][0] * A[0][1] * A[2][2] * A[1][3]
+ A[3][0] * A[1][1] * A[0][2] * A[2][3]
+ A[3][0] * A[2][1] * A[1][2] * A[0][3]
- A[0][0] * A[1][1] * A[3][2] * A[2][3]
- A[0][0] * A[2][1] * A[1][2] * A[3][3]
- A[0][0] * A[3][1] * A[2][2] * A[1][3]
- A[1][0] * A[0][1] * A[2][2] * A[3][3]
- A[1][0] * A[2][1] * A[3][2] * A[0][3]
- A[1][0] * A[3][1] * A[0][2] * A[2][3]
- A[2][0] * A[0][1] * A[3][2] * A[1][3]
- A[2][0] * A[1][1] * A[0][2] * A[3][3]
- A[2][0] * A[3][1] * A[1][2] * A[0][3]
- A[3][0] * A[0][1] * A[1][2] * A[2][3]
- A[3][0] * A[1][1] * A[2][2] * A[0][3]
- A[3][0] * A[2][1] * A[0][2] * A[1][3]
)
return self.__class__._of(noms, self.denom**4)
raise Exception("FracVector.det: on non 3x3 or 4x4 matrix not implemented. Matrix was:" + str(dim))
[docs]
def inv(self) -> Self:
"""
Return the matrix inverse, ``A^-1``.
:return: The inverse scalar or matrix.
"""
dim = self.dim
if dim == ():
# For a FracScalar, just swap denominator and nominator
return self.__class__._of(self.denom, self.nom)
if dim != (3, 3):
raise Exception("FracVector.inv: only scalar and 3x3 matrix implemented")
# We are dividing with a determinant giving self.denom**3 in nominator, and
# from the matrix 1/self.denom**2 falls out -> one factor of self.denom in nominator
det = self.det()
det_nom = det.nom
if det_nom == 0:
raise Exception("FracVector.inverse: cannot take inverse of singular matrix.")
if det_nom < 0:
denom = -det_nom
m = -self.denom
else:
denom = det_nom
m = self.denom
A = cast(Any, self.noms)
noms = self._dup_noms(
(
self._dup_noms(
(
m * (A[1][1] * A[2][2] - A[1][2] * A[2][1]),
m * (A[0][2] * A[2][1] - A[0][1] * A[2][2]),
m * (A[0][1] * A[1][2] - A[0][2] * A[1][1]),
),
),
self._dup_noms(
(
m * (A[1][2] * A[2][0] - A[1][0] * A[2][2]),
m * (A[0][0] * A[2][2] - A[0][2] * A[2][0]),
m * (A[0][2] * A[1][0] - A[0][0] * A[1][2]),
),
),
self._dup_noms(
(
m * (A[1][0] * A[2][1] - A[1][1] * A[2][0]),
m * (A[0][1] * A[2][0] - A[0][0] * A[2][1]),
m * (A[0][0] * A[1][1] - A[0][1] * A[1][0]),
),
),
)
)
return self.__class__._of(noms, denom)
[docs]
def simplify(self) -> Self:
"""
Return a reduced FracVector. I.e., each element has the same numerical value but the
new FracVector represents them using the smallest possible shared denominator.
The result is *canonical*: two numerically equal FracVectors always simplify to the
same ``(denom, noms)`` pair. That requires normalizing the sign as well as reducing
by the greatest common divisor, since ``(1, 0, 0)/-2`` and ``(-1, 0, 0)/2`` are the
same value and neither is reducible. Canonicality is what ``__hash__`` relies
on to stay consistent with ``__eq__``.
:return: The reduced, canonical vector.
"""
noms = self.noms
denom = self.denom
if denom != 1:
gcd = self._reduce_over_noms(lambda x, y: calc_gcd(x, abs(y)), initializer=abs(denom))
# Dividing through by a negative divisor reduces and flips the sign in one pass,
# which keeps this to a single traversal of the numerators. The division is
# always exact, since the divisor divides the denominator and every numerator.
divisor = gcd if denom > 0 else -gcd
if divisor != 1:
denom = denom // divisor
noms = self._map_over_noms(lambda x: x // divisor)
return self.__class__._of(noms, denom)
# TODO: Integrate improvements in simplify_fast with simplify
[docs]
def simplify_fast(self, depth: int) -> Self:
"""
Return a reduced FracVector, taking advantage of a known nesting ``depth``. I.e., each
element has the same numerical value but the new FracVector represents them using the
smallest possible shared denominator.
:param depth: The known nesting depth of the nominators.
:return: The reduced vector.
"""
noms = self.noms
denom = self.denom
if self.denom != 1:
if depth == 1:
gcd = calc_gcd(cast(int, noms), denom)
elif depth == 2:
gcd = reduce(
lambda sub_ls1, sub_ls2: reduce(
lambda nom1, nom2: calc_gcd(nom1, abs(nom2)), # type: ignore[arg-type]
sub_ls2, # type: ignore[arg-type]
sub_ls1,
),
noms, # type: ignore[arg-type]
denom,
)
elif depth == 3:
gcd = reduce(
lambda sub_ls1, sub_ls2: reduce(
lambda sub_sub_ls1, sub_sub_ls2: reduce(
lambda nom1, nom2: calc_gcd(nom1, abs(nom2)), # type: ignore[arg-type]
sub_sub_ls2, # type: ignore[arg-type]
sub_sub_ls1,
),
sub_ls2, # type: ignore[arg-type]
sub_ls1,
),
noms, # type: ignore[arg-type]
denom,
)
else:
raise Exception("FracVector.simplify_fast: only depth 1, 2 or 3 are supported, got depth " + str(depth))
if gcd != 1:
denom = denom // gcd
noms = self._map_over_noms(lambda x: x // gcd)
return self.__class__._of(noms, denom)
[docs]
def set_denominator(self, set_denom: int = 1000000000) -> Self:
"""
Return a FracVector of reduced resolution where every element is the closest numerical
approximation using this denominator.
:param set_denom: The denominator to use for the approximation.
:return: The approximated vector.
"""
denom = self.denom
def limit_resolution_one(x: int) -> int:
low = (x * set_denom) // denom
if x * set_denom * 2 > (low * 2 + 1) * denom:
return low + 1
else:
return low
noms = self._map_over_noms(limit_resolution_one)
return self.__class__._of(noms, set_denom)
[docs]
def limit_denominator(self, max_denom: int = 1000000000) -> Self:
"""
Return a FracVector of reduced resolution.
Each element in the returned FracVector is the closest numerical approximation that is
allowed by a fraction with maximally this denominator. Note: since all elements must be
put on a common denominator, the result may have a larger denominator than ``max_denom``.
:param max_denom: The largest denominator allowed for each element's approximation.
:return: The approximated vector.
"""
denom = self.denom
newvalues = self._map_over_noms(lambda x: fractions.Fraction(x, denom).limit_denominator(max_denom))
return self.__class__(newvalues)
[docs]
def floor(self) -> int:
"""
Return the integer that is equal to or just below the value stored in a scalar FracVector.
:return: The floor of the scalar value.
"""
if self.dim != ():
raise Exception("FracVector.floor: Needs scalar FracVector")
# Python integer division really does floor, even for negative numbers
return self.nom // self.denom
[docs]
def modf(self) -> tuple["FracVector", "FracVector"]:
"""
Return the fractional and integer parts of each element as the pair
``(fractional, integer)`` of exact FracVectors sharing this vector's denominator.
Both parts carry the sign of the element and the integer part truncates toward
zero, matching the conventions of :func:`math.modf` (e.g. the value -5/2 splits
into -1/2 and -2).
:return: The fractional and integer parts, in that order.
"""
denom = self.denom
def trunc_scaled(nom: int) -> int:
return (nom // denom if nom >= 0 else -((-nom) // denom)) * denom
integer_noms = self._map_over_noms(trunc_scaled)
fractional_noms = self._map_over_noms(lambda nom: nom - trunc_scaled(nom))
return (
FracVector._of(fractional_noms, denom),
FracVector._of(integer_noms, denom),
)
[docs]
def ceil(self) -> int:
"""
Return the integer that is equal to or just above the value stored in a scalar FracVector.
:return: The ceiling of the scalar value.
"""
if self.dim != ():
raise Exception("FracVector.ceil: Needs scalar FracVector")
if self.nom % self.denom == 0:
return self.nom // self.denom
else:
return self.nom // self.denom + 1
[docs]
def normalize(self) -> Self:
"""
Add/remove an integer +/-N to each element to place it in the range [0, 1).
:return: The normalized vector.
"""
noms = self._map_over_noms(lambda x: x - self.denom * (x // self.denom))
return self.__class__._of(noms, self.denom)
[docs]
def normalize_half(self) -> Self:
"""
Add/remove an integer +/-N to each element to place it in the range [-1/2, 1/2).
This is useful to find the shortest vector C between two points A, B in a space with
periodic boundary conditions [0, 1)::
C = (A - B).normalize_half()
:return: The vector normalized into the half-open interval.
"""
noms = self._map_over_noms(lambda x: 2 * x - (2 * self.denom) * ((((2 * x) // self.denom) + 1) // 2))
return self.__class__._of(noms, 2 * self.denom)
[docs]
def mul(self, other: Any) -> Self:
"""
Return the result of multiplying the vector with ``other`` using matrix multiplication.
Note that for two 1D FracVectors, ``A.dot(B)`` is *not* the same as ``A.mul(B)``, but
rather ``A.mul(B.T())``.
:param other: The vector or scalar to multiply.
:return: The exact matrix product.
"""
# Handle other being another object
if not isinstance(other, FracVectorBase):
other = FracVector(other)
Adim = self.dim
Bdim = other.dim
A = cast(Any, self.noms)
B = cast(Any, other.noms)
denom = self.denom * other.denom
# Other is scalar
if Bdim == ():
m = other.nom
noms = self._map_over_noms(lambda x: x * m)
# Self is scalar
elif Adim == ():
m = self.nom
noms = other._map_over_noms(lambda x: x * m)
# Vector * Vector
elif len(Adim) == 1 and len(Bdim) == 1:
if Adim[0] != Bdim[0]:
raise Exception(
"FracVector.dot: vector multiplication dimension mismatch," + str(Adim) + " and " + str(Bdim)
)
noms = self._dup_noms(A[i] * B[i] for i in range(Adim[0]))
# Matrix * vector
elif len(Adim) == 2 and len(Bdim) == 1:
if Adim[1] != Bdim[0]:
raise Exception(
"FracVector.dot: matrix multiplication dimension mismatch," + str(Adim) + " and " + str(Bdim)
)
noms = self._dup_noms(sum([A[row][i] * B[i] for i in range(Adim[1])]) for row in range(Adim[0]))
# vector * Matrix
elif len(Adim) == 1 and len(Bdim) == 2:
if Adim[0] != Bdim[0]:
raise Exception(
"FracVector.dot: matrix multiplication dimension mismatch," + str(Adim) + " and " + str(Bdim)
)
noms = self._dup_noms(sum([A[i] * B[i][col] for i in range(Adim[0])]) for col in range(Bdim[1]))
# Matrix * Matrix
elif len(Adim) == 2 and len(Bdim) == 2:
if Adim[1] != Bdim[0]:
raise Exception(
"FracVector.dot: matrix multiplication dimension mismatch," + str(Adim) + " and " + str(Bdim)
)
noms = self._dup_noms(
self._dup_noms(sum([A[row][i] * B[i][col] for i in range(Adim[1])]) for col in range(Bdim[1]))
for row in range(Adim[0])
)
else:
raise Exception(
"FracVector.dot: cannot handle tensors of order > 2, dimensions:" + str(Adim) + " and " + str(Bdim)
)
return self.__class__._of(noms, denom)
[docs]
def dot(self, other: "FracVector") -> Self:
"""
Return the vector dot product of the 1D vector with the 1D vector ``other``, i.e.,
``A . B``. The same as ``A * B.T()``.
:param other: The other 1-D vector.
:return: The exact dot product.
"""
Adim = self.dim
Bdim = other.dim
A = cast(Any, self.noms)
B = cast(Any, other.noms)
denom = self.denom * other.denom
if len(Adim) == 1 and len(Bdim) == 1:
if Adim[0] != Bdim[0]:
raise Exception(
"FracVector.dot: vector multiplication dimension mismatch," + str(Adim) + " and " + str(Bdim)
)
noms = sum(A[i] * B[i] for i in range(Adim[0]))
else:
raise Exception("FracVector.dot: dot multiplication dimensions not = 1," + str(Adim) + " and " + str(Bdim))
return self.__class__._of(noms, denom)
[docs]
def lengthsqr(self) -> Self:
"""
Return the square of the length of the vector. The same as ``A * A.T()``.
:return: The exact squared length.
"""
# Other is scalar
dim = self.dim
noms_src = cast(Any, self.noms)
if dim == ():
noms = noms_src**2
elif len(self.dim) == 1:
noms = sum(noms_src[i] ** 2 for i in range(self.dim[0]))
else:
raise Exception("FracVector.lengthsqr: vector must be scalar or dimension must be = 1, is " + str(self.dim))
return self.__class__._of(noms, self.denom**2)
[docs]
def cross(self, other: "FracVector") -> Self:
"""
Return the vector cross product of the 3-element 1D vector with the 3-element 1D vector
``other``, i.e., ``A x B``.
:param other: The other 3-element vector.
:return: The exact cross product.
"""
# Note: multiplication is an especially simple case, there is no need to bring the two
# vectors into a common denom with set_common_denom, since a/b * c/d = a*c/(b*d)
Adim = self.dim
A = cast(Any, self.noms)
Bdim = other.dim
B = cast(Any, other.noms)
denom = self.denom * other.denom
if Adim != (3,) or Bdim != (3,):
raise Exception(
"FracVector.cross: can only do cross products of 3-element 1D vectors. The dimensions are:"
+ str(Adim)
+ " and "
+ str(Bdim)
)
noms = (
(A[1] * B[2] - A[2] * B[1]),
(A[2] * B[0] - A[0] * B[2]),
(A[0] * B[1] - A[1] * B[0]),
)
return self.__class__._of(noms, denom)
[docs]
def reciprocal(self) -> Self:
"""
Return the reciprocal matrix of a 3x3 matrix (the rows are the reciprocal vectors,
without the ``2*pi`` factor).
:return: The reciprocal matrix.
"""
dim = self.dim
if dim != (3, 3):
raise Exception(
"FracVector.reciprocal: can only calculate reciprocal matrix for a 3,3 matrix. The dimension are:"
+ str(dim)
)
noms = cast(Any, self.noms)
def det_noms(A: Any) -> Any:
return (
A[0][0] * A[1][1] * A[2][2]
+ A[0][1] * A[1][2] * A[2][0]
+ A[0][2] * A[1][0] * A[2][1]
- A[0][2] * A[1][1] * A[2][0]
- A[0][1] * A[1][0] * A[2][2]
- A[0][0] * A[1][2] * A[2][1]
)
def cross_noms(A: Any, B: Any) -> Any:
return (
(A[1] * B[2] - A[2] * B[1]),
(A[2] * B[0] - A[0] * B[2]),
(A[0] * B[1] - A[1] * B[0]),
)
detnom = det_noms(noms)
denom = self.denom
v1, v2, v3 = noms[0], noms[1], noms[2]
noms = (cross_noms(v2, v3), cross_noms(v3, v1), cross_noms(v1, v2))
noms = self.nested_map(lambda x: x * denom, noms)
return self.__class__._of(noms, detnom)
[docs]
def metric_product(self, vecA: "FracVector", vecB: "FracVector") -> Self:
"""
Return the result of the metric product using the present square FracVector as the
metric matrix. The same as ``vecA * self * vecB.T()``.
:param vecA: The first vector or matrix operand.
:param vecB: The second vector or matrix operand.
:return: The metric product.
"""
dimM = cast(Any, self.dim)
dimA = cast(Any, vecA.dim)
dimB = cast(Any, vecB.dim)
M = cast(Any, self.noms)
A = cast(Any, vecA.noms)
B = cast(Any, vecB.noms)
denom = vecA.denom * vecB.denom * self.denom
n = dimM[0]
if dimA != dimB or dimM != (n, n) or ((len(dimA) != 1 or len(dimB) != 1) and (dimA[1] != n or dimB[1] != n)):
raise Exception("FracVector.metric_product: vectors not in right dimensions.")
noms: Any
if len(dimA) == 1:
noms = sum([A[row] * M[row][col] * B[col] for row in range(n) for col in range(n)])
else:
# Matrix * Matrix
noms = [
sum([A[i][row] * M[row][col] * B[i][col] for row in range(n) for col in range(n)])
for i in range(dimA[0])
]
return self.__class__._of(noms, denom)
[docs]
def cos(
self,
prec: fractions.Fraction | None = None,
degrees: bool = False,
limit: bool = False,
) -> Self:
"""
Return a FracVector where every element is the cosine of the element in the source FracVector.
:param prec: The requested approximation precision.
:param degrees: Whether to interpret the elements in degrees.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise cosine vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.cos(
fractions.Fraction(nom, self.denom),
prec=prec,
limit=limit,
degrees=degrees,
)
)
else:
fracs = self._map_over_noms(
lambda nom: exactmath.cos(fractions.Fraction(nom, self.denom), limit=limit, degrees=degrees)
)
return self.__class__(fracs)
[docs]
def sin(
self,
prec: fractions.Fraction | None = None,
degrees: bool = False,
limit: bool = False,
) -> Self:
"""
Return a FracVector where every element is the sine of the element in the source FracVector.
:param prec: The requested approximation precision.
:param degrees: Whether to interpret the elements in degrees.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise sine vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.sin(
fractions.Fraction(nom, self.denom),
prec=prec,
limit=limit,
degrees=degrees,
)
)
else:
fracs = self._map_over_noms(
lambda nom: exactmath.sin(fractions.Fraction(nom, self.denom), limit=limit, degrees=degrees)
)
return self.__class__(fracs)
[docs]
def acos(
self,
prec: fractions.Fraction | None = None,
degrees: bool = False,
limit: bool = False,
) -> Self:
"""
Return a FracVector where every element is the arccos of the element in the source FracVector.
:param prec: The requested approximation precision.
:param degrees: Whether to return the result in degrees.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise arccosine vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.acos(
fractions.Fraction(nom, self.denom),
prec=prec,
limit=limit,
degrees=degrees,
)
)
else:
fracs = self._map_over_noms(
lambda nom: exactmath.acos(fractions.Fraction(nom, self.denom), limit=limit, degrees=degrees)
)
return self.__class__(fracs)
[docs]
def asin(
self,
prec: fractions.Fraction | None = None,
degrees: bool = False,
limit: bool = False,
) -> Self:
"""
Return a FracVector where every element is the arcsin of the element in the source FracVector.
:param prec: The requested approximation precision.
:param degrees: Whether to return the result in degrees.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise arcsine vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.asin(
fractions.Fraction(nom, self.denom),
prec=prec,
limit=limit,
degrees=degrees,
)
)
else:
fracs = self._map_over_noms(
lambda nom: exactmath.asin(fractions.Fraction(nom, self.denom), limit=limit, degrees=degrees)
)
return self.__class__(fracs)
[docs]
def exp(self, prec: fractions.Fraction | None = None, limit: bool = False) -> Self:
"""
Return a FracVector where every element is the exponent of the element in the source FracVector.
:param prec: The requested approximation precision.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise exponential vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.exp(fractions.Fraction(nom, self.denom), prec=prec, limit=limit)
)
else:
fracs = self._map_over_noms(lambda nom: exactmath.exp(fractions.Fraction(nom, self.denom), limit=limit))
return self.__class__(fracs)
[docs]
def sqrt(self, prec: fractions.Fraction | None = None, limit: bool = False) -> Self:
"""
Return a FracVector where every element is the sqrt of the element in the source FracVector.
:param prec: The requested approximation precision.
:param limit: Whether to limit the denominator to at most ``1 / prec``.
:return: The elementwise square-root vector.
"""
if prec is not None:
fracs = self._map_over_noms(
lambda nom: exactmath.sqrt(fractions.Fraction(nom, self.denom), prec=prec, limit=limit)
)
else:
fracs = self._map_over_noms(lambda nom: exactmath.sqrt(fractions.Fraction(nom, self.denom), limit=limit))
return self.__class__(fracs)
#### Python special overloading
def __getitem__(self, key: Any) -> Self:
if not isinstance(key, tuple):
key = (key,)
noms = tuple_slice(self.noms, key)
return self.__class__._of(noms, self.denom)
def __setitem__(self, key: Any, values: Any) -> None:
raise Exception("FracVector is immutable, use MutableFracVector instead.")
def __len__(self) -> int:
if isinstance(self.noms, (list, tuple)):
return len(self.noms)
else:
return 0
def __iter__(self) -> Any:
try:
if self.dim != ():
noms = cast(Any, self.noms)
for i in range(len(noms)):
yield self.__class__._of(noms[i], self.denom)
else:
yield self
except GeneratorExit:
pass
def __mul__(self, other: Any) -> Self:
return self.mul(other)
def __rmul__(self, other: Any) -> "FracVector":
other = FracVector(other)
return other.mul(self)
def __pow__(self, exp: int) -> Self:
if exp == -1:
return self.inv()
if self.dim == ():
if exp == 0:
# Use the raw constructor so the scalar result keeps its exact representation.
return self.__class__._of(1, 1)
if exp > 0:
return self.__class__._of(self.nom**exp, self.denom**exp)
if exp < 0:
return self.__class__._of(self.denom ** (-exp), self.nom ** (-exp))
if isinstance(exp, int):
if exp == 0:
return self.eye(self.dim)
if exp > 0:
a = self
for _ in range(exp - 1):
a = a.mul(self)
return a
if exp < 0:
# A^(-n) = (A^-1)^n: keep multiplying by the inverse, not by self. (The legacy
# loop multiplied by self, so e.g. A**-2 collapsed to the identity.)
inv = self.inv()
a = inv
for _ in range(-exp - 1):
a = a.mul(inv)
return a
raise Exception("FracVector.__pow__: unreachable")
else:
raise Exception("FracVector.__pow__: I do not know how to exponate a FracVector with " + str(exp))
def __truediv__(self, other: Any) -> Self:
if not isinstance(other, FracVectorBase):
other = FracVector(other)
frac = self.__class__._of(other.denom, other.nom)
return self.mul(frac)
def __add__(self, other: Any) -> Self:
noms, denom = self._map_binary_op_over_noms(operator.add, other)
return self.__class__._of(noms, denom)
def __radd__(self, other: Any) -> Self:
noms, denom = self._map_binary_op_over_noms(operator.add, other)
return self.__class__._of(noms, denom)
def __sub__(self, other: Any) -> Self:
noms, denom = self._map_binary_op_over_noms(operator.sub, other)
return self.__class__._of(noms, denom)
def __rsub__(self, other: Any) -> Self:
minusself = -self
noms, denom = minusself._map_binary_op_over_noms(operator.sub, -other)
return self.__class__._of(noms, denom)
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.noms!r}, denom={self.denom!r})"
def __str__(self) -> str:
return "(1/" + str(self.denom) + ")*" + str(self.noms)
def __hash__(self) -> int:
"""
A hash consistent with ``__eq__``, which compares *numerically*.
The stored ``(denom, noms)`` pair cannot be hashed directly: ``(1, 0, 0)/2`` and
``(2, 0, 0)/4`` are equal but represented differently, so hashing the raw pair
would put equal vectors in different hash buckets and let a ``set`` or ``dict``
hold what are really duplicates. Hashing the canonical form from :meth:`FracVector.simplify`
removes the ambiguity. The result is cached, since a FracVector is immutable.
"""
if self._hash_cache is None:
simplified = self.simplify()
self._hash_cache = hash((simplified.denom, simplified.noms))
return self._hash_cache
def __neg__(self) -> Self:
return self.__class__._of(self._map_over_noms(operator.neg), self.denom)
def __abs__(self) -> Self:
return self.__class__._of(self._map_over_noms(operator.abs), self.denom)
def __eq__(self, other: object) -> bool:
"""
Important: the == operator between FracVectors tests for numerical equality. (I.e.,
numerically equal FracVectors with different denoms are still equal.)
"""
# Note: somewhat optimized for speed
other = cast(FracVector, other)
try:
if self.denom == other.denom:
return _noms_equal(self.noms, other.noms)
else:
A, B, _ = self.set_common_denom(self, other)
return _noms_equal(A.noms, B.noms)
except AttributeError:
if other is None:
return False
if not isinstance(other, FracVectorBase):
other = FracVector(other)
if other.dim != self.dim:
return False
if self.denom == other.denom:
return _noms_equal(self.noms, other.noms)
else:
A, B, _ = self.set_common_denom(self, other)
return _noms_equal(A.noms, B.noms)
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __lt__(self, other: Any) -> bool:
try:
return self.nom * other.denom < other.nom * self.denom
except AttributeError:
return self.nom < other * self.denom
def __gt__(self, other: Any) -> bool:
try:
return self.nom * other.denom > other.nom * self.denom
except AttributeError:
return self.nom > other * self.denom
def __le__(self, other: Any) -> bool:
return not self.__gt__(other)
def __ge__(self, other: Any) -> bool:
return not self.__lt__(other)
def __float__(self) -> float:
# This way of converting avoids many possible overflow errors
return float(fractions.Fraction(self.nom, self.denom))
def __int__(self) -> int:
return int(fractions.Fraction(self.nom, self.denom))
def __index__(self) -> int:
v = self.simplify()
if v.denom != 1:
raise Exception("FracVector.__index__: cannot index with non-integer value.")
return v.nom
def __complex__(self) -> complex:
return complex(self.__float__())
[docs]
def max(self) -> Self:
"""
Return the maximum element across all dimensions in the FracVector. ``max(fracvector)``
works for a 1D vector.
:return: The maximum scalar element.
"""
return max(self.flatten())
[docs]
def nargmax(self) -> list[Any]:
"""
Return a list of indices of all maximum elements across all dimensions in the FracVector.
:return: The indices of all maximum elements.
"""
idt = tuple_index(self.dim)
maxval = self.max()
indices = nested_reduce_levels(lambda x, y: x + [y] if self[y] == maxval else x, idt, len(self.dim), [])
return indices
[docs]
def argmax(self) -> Any:
"""
Return the index of the maximum element across all dimensions in the FracVector.
:return: The index of one maximum element.
"""
idt = tuple_index(self.dim)
flat_idt = nested_reduce_levels(lambda x, y: x + [y], idt, len(self.dim), initializer=[])
return max(flat_idt, key=lambda i: self[i])
[docs]
def min(self) -> Self:
"""
Return the minimum element across all dimensions in the FracVector. ``min(fracvector)``
works for a 1D vector.
:return: The minimum scalar element.
"""
return min(self.flatten())
[docs]
def nargmin(self) -> list[Any]:
"""
Return a list of indices for all minimum elements across all dimensions in the FracVector.
:return: The indices of all minimum elements.
"""
idt = tuple_index(self.dim)
minval = self.min()
indices = nested_reduce_levels(lambda x, y: x + [y] if self[y] == minval else x, idt, len(self.dim), [])
return indices
[docs]
def argmin(self) -> Any:
"""
Return the index of the minimum element across all dimensions in the FracVector.
:return: The index of one minimum element.
"""
idt = tuple_index(self.dim)
flat_idt = nested_reduce_levels(lambda x, y: x + [y], idt, len(self.dim), initializer=[])
return min(flat_idt, key=lambda i: self[i])
#### Private methods
def _map_over_noms(self, op: Callable[..., Any], *others: "FracVectorBase") -> Any:
"""
Map an operation over all nominators.
"""
othernoms = [x.noms for x in others]
if isinstance(self.noms, (tuple, list)):
return self.nested_map(op, self.noms, *othernoms)
else:
return op(self.noms, *othernoms)
def _map_binary_op_over_noms(self, op: Callable[..., Any], other: Any) -> tuple[Any, int]:
"""
Put self and other on common denominator form, and then map a binary operator over pairs
of nominators, handling the cases where either of the operands is a scalar (thus pairing
it with every nominator).
"""
A, B, denom = self.set_common_denom(self, other)
Adim = A.dim
Bdim = B.dim
if len(Adim) == 0:
if len(Bdim) == 0:
# scalar [op] scalar
result = op(A.nom, B.nom)
else:
# scalar [op] (Matrix or Vector)
result = B._map_over_noms(lambda x: op(A.nom, x))
elif len(Bdim) == 0:
# [Matrix or Vector] op scalar
result = A._map_over_noms(lambda x: op(x, B.nom))
else:
# Matrix op Matrix
result = A._map_over_noms(lambda x, y: op(x, y), B)
return (result, denom)
def _reduce_over_noms(self, op: Callable[[Any, Any], Any], initializer: Any = None) -> Any:
"""
Run a nested reduce operation over all nominators.
"""
return nested_reduce(op, self.noms, initializer=initializer)
[docs]
class FracVector(FracVectorBase, VectorBackend):
"""Immutable exact-rational vector that is also its own vector backend."""
@property
[docs]
def fractions(self) -> Fractions:
"""Return this vector in the exact nested Fraction interchange format."""
return _fracvector_to_fractions(self)
@classmethod
def _backend_adopt(cls, obj: Any, **hints: Any) -> "FracVector | None":
r"""Adopt an immutable exact-rational vector by identity.
:param obj: The object to adopt.
:param \**hints: Backend-selection and disambiguation hints.
:return: ``obj`` when it is an exact-rational backend of kind ``"frac"``.
"""
if not isinstance(obj, FracVector):
return None
if hints and hints.get("kind", "frac") != "frac":
return None
return obj
[docs]
class FracScalar(FracVector):
"""
Represents the fractional number ``nom/denom``. This is a subclass of FracVector with the
purpose of making it clear when a scalar fracvector is needed/used.
Convert a value into a FracScalar.
``FracScalar(something)`` where ``something`` may be any object that can be used
in the constructor of the Python Fraction class (also works with strings!).
For signature compatibility with the :class:`~httk.core.vectors.fracvector.FracVector` constructor, this accepts but ignores
``chain`` and ``min_accuracy``, and converts strings exactly via the Fraction constructor.
:param value: The scalar value or values to convert.
:param denom: An optional additional denominator.
:param simplify: Whether to reduce the resulting denominator.
:param chain: An accepted compatibility parameter; it does not affect scalar creation.
:param min_accuracy: An accepted compatibility parameter; scalar strings are exact.
"""
def __init__(
self,
value: Any,
*,
denom: int | None = None,
simplify: bool = False,
chain: bool = False,
min_accuracy: fractions.Fraction | None = fractions.Fraction(1, 10000),
) -> None:
def lcd_op(a: Any, y: Any) -> Any:
try:
b = abs(fractions.Fraction(y)).denominator
except TypeError:
b = abs(fractions.Fraction(str(y))).denominator
return a * b // calc_gcd(a, b)
def frac(x: Any) -> Any:
return (fractions.Fraction(x) * lcd).numerator
lcd = nested_reduce_fractions(lambda x, y: lcd_op(x, y), value, initializer=1)
v_noms = self.nested_map_fractions(lambda x: frac(x), value)
if denom is None:
denominator = lcd
else:
denominator = lcd * denom
self._assign_raw(v_noms, denominator)
self._dim = ()
if simplify and self.denom != 1:
simplified = self.simplify()
self._assign_raw(simplified.noms, simplified.denom)
self._dim = ()
@classmethod
def _of(cls, noms: Noms, denom: int = 1) -> Self:
"""Build from a trusted raw integer nominator and denominator, without validation."""
instance = object.__new__(cls)
instance._assign_raw(noms, denom)
instance._dim = ()
return instance