diff --git a/.claude/rules/python.md b/.claude/rules/python.md index 008619a..269caf7 100644 --- a/.claude/rules/python.md +++ b/.claude/rules/python.md @@ -53,7 +53,7 @@ Apply these rules to ALL new and modified Python code. This project is a Python ### Types -- NEVER use type annotations in the src directory tree. Types of input parameters and returns should be indicated in the docstrings. +- NEVER use type annotations in the src directory tree, with one exception. Types of input parameters and returns should be indicated in the docstrings. The exception is a property, which may carry an inline return annotation so that the rendered documentation shows the type beside the property name; a property annotated this way keeps a one-line docstring rather than a `Returns:` block. - Annotate all test function/method parameters and return values, including `-> None` for functions (and `__init__`) that return nothing. - Use modern generic syntax (`list[str]`, `dict[str, int]`, `X | None`) for Python 3.11+. diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c499f8a..209e4a2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -29,7 +29,7 @@ jobs: # Ruff is the linter of record for every rule it implements. # TODO Add `ruff format --check src tests` once the source has been - # reformatted; mypy stays off while src is deliberately unannotated. + # reformatted. - name: Ruff run: | ruff check src tests @@ -42,15 +42,25 @@ jobs: run: | flake8 --select=E12,E13 src tests + # Every module under tests/ is checked. src/ is deliberately unannotated and + # carries no stub of its own, so mypy is kept off it by the excludes and the + # overrides in pyproject.toml; this step matches `--mypy` in + # scripts/run-all-checks.sh. + - name: Mypy + run: | + MYPYPATH=src mypy tests + - name: Pyroma run: | pyroma . - # The published .pyi stubs must keep describing the runtime API, which is assembled - # dynamically: most of Qube's methods are bound on at import time. + # The two published stubs, __init__.pyi and typedefs.pyi, must keep describing the + # runtime API, which is assembled dynamically: most of Qube's methods are bound on + # at import time. The allowlist accepts only the deliberate absence of stubs for + # the internal modules. - name: Stubtest run: | - python -m mypy.stubtest polymath --mypy-config-file pyproject.toml + python -m mypy.stubtest polymath --mypy-config-file pyproject.toml --allowlist .stubtest-allowlist # -W makes warnings errors; docs/conf.py sets nitpicky = True, so a # cross-reference with no target fails the build too. diff --git a/.stubtest-allowlist b/.stubtest-allowlist new file mode 100644 index 0000000..4e824f7 --- /dev/null +++ b/.stubtest-allowlist @@ -0,0 +1,21 @@ +# Allowlist for `mypy.stubtest`, read by scripts/run-all-checks.sh and CI. +# +# The only supported imports are "from polymath import ..." and +# "from polymath.typedefs import ...", so __init__.pyi and typedefs.pyi are the only +# stubs and describe the whole public API. The modules below are implementation +# detail and deliberately carry no stub; stubtest reports each one as "failed to +# find stubs", which this file accepts. Nothing else is accepted: a public name +# missing from the two stubs still fails the check. +polymath\.qube +polymath\.unit +polymath\.scalar +polymath\.boolean +polymath\.vector +polymath\.pair +polymath\.vector3 +polymath\.quaternion +polymath\.polynomial +polymath\.matrix +polymath\.matrix3 +polymath\.extensions +polymath\.extensions\.[a-z_]+ diff --git a/CLAUDE.md b/CLAUDE.md index ab042b9..aacf594 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,16 +44,30 @@ required to run exactly that set. Run it after any change. concatenation) and `I001` (its fix collapses the column-aligned imports used throughout). Read the comment before re-enabling one. - Single quotes (`[tool.ruff.format] quote-style = "single"`). -- **Never use type annotations anywhere under `src/`** — parameter and return types belong in the - docstrings. **Annotate all test functions and methods**, including `-> None`. -- The package ships a PEP 561 `py.typed` marker, so public type information goes in `.pyi` stubs - alongside the modules. A stub replaces its module entirely for type checkers: whatever the stub - omits becomes invisible downstream, so a new stub must cover the module's whole public surface. - `stubtest` enforces exactly that and runs in the check script and in CI, so adding, renaming or - re-signing any public member means updating its stub in the same change. Most of `Qube`'s methods - are bound on at import time from `extensions/`, and they all have to appear in `qube.pyi`. - Signature shapes in the stubs are exact; types come from the docstrings where those state one - and are `Any` where they do not, which is deliberate rather than an omission to fill in blindly. +- **No type annotations under `src/`, with one exception** — parameter and return types belong in + the docstrings. The exception is a **property**, which may carry an inline return annotation: + a property has no parameters, and Sphinx renders the annotation as the property's type beside + its name, so `Qube.shape` reads as `property shape: tuple[int, ...]`. A property documented only + through a `Returns:` block renders the type in a separate trailing line instead, so the two + styles do not mix; annotate the property and leave its docstring a one-line summary. Where the + annotation names something from `polymath.typedefs`, quote it and import it under + `if TYPE_CHECKING:` — that module imports `Qube`, so a runtime import from `qube.py` is + circular. **Annotate all test functions and methods**, including `-> None`. +- The package ships a PEP 561 `py.typed` marker, and **exactly two stubs** carry the public type + information: `__init__.pyi`, which declares every public class in full, and `typedefs.pyi`. The + only supported imports are `from polymath import ...` and `from polymath.typedefs import ...`, so + no other module has a stub and none may be added: a per-module stub would make an import such as + `from polymath.scalar import ...` look supported. A stub replaces its module entirely for type + checkers: whatever the stub omits becomes invisible downstream, so the two stubs must cover the + whole public surface. `stubtest` enforces exactly that and runs in the check script and in CI, so + adding, renaming or re-signing any public member means updating `__init__.pyi` in the same change. + Most of `Qube`'s methods are bound on at import time from `extensions/`, and they all have to + appear under `Qube` in `__init__.pyi`. Signature shapes in the stubs are exact; types come from + the docstrings where those state one, from the inline annotation for a property that has one, and + are `Any` where neither does, which is deliberate rather than an omission to fill in blindly. + stubtest would otherwise compare each stub-less module against itself, so `[tool.mypy] exclude` + and the override list in `pyproject.toml`, and `.stubtest-allowlist`, each name those modules + explicitly; a new module goes in all three. - `qube.py` holds only what defines an object: the class constants, `__init__`, the construction path, low-level access, the properties and the cache. Everything else lives in `extensions/` and is bound onto `Qube` by `extensions/__init__.py`. Two rules keep that working. First, diff --git a/README.md b/README.md index 6d8988a..108bb2d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,30 @@ from polymath import (Boolean, Matrix, Matrix3, Pair, Quaternion, Qube, Scalar, Vector, Vector3) ``` +# Type Annotations + +PolyMath ships type stubs and a `py.typed` marker, so a type checker such as mypy +understands the signatures of every class. The +`polymath.typedefs`[![image](https://raw.githubusercontent.com/SETI/rms-polymath/main/icons/link.png)](https://rms-polymath.readthedocs.io/en/latest/module.html#module-polymath.typedefs) +module supplements the stubs with aliases naming what each constructor accepts, for use in +your own annotations: + +```python +from polymath import Scalar, Vector3 +from polymath.typedefs import Vector3Like + +def speed(velocity: Vector3Like) -> Scalar: + return Vector3.as_vector3(velocity).norm() +``` + +`ScalarLike` accepts anything that `Scalar` converts: a number, a nested sequence, a NumPy +array, or any PolyMath object. `BooleanLike`, `PairLike`, `VectorLike`, `Vector3Like`, +`MatrixLike`, `Matrix3Like`, `QuaternionLike`, and `QubeLike` do the same for the other +classes, and `ValsType` and `MaskType` name what the `values` and `mask` properties return. +Each alias is an ordinary runtime object, so it can be imported and used anywhere. See the +[User Guide](https://rms-polymath.readthedocs.io/en/latest/user_guide/user_guide_typing.html) +for details. + # Features The PolyMath classes are: @@ -779,6 +803,8 @@ Information on contributing to this package can be found in the # Links * [Documentation](https://rms-polymath.readthedocs.io) +* [User Guide](https://rms-polymath.readthedocs.io/en/latest/user_guide/user_guide.html) +* [Developer Guide](https://rms-polymath.readthedocs.io/en/latest/dev_guide/dev_guide.html) * [Repository](https://github.com/SETI/rms-polymath) * [Issue tracker](https://github.com/SETI/rms-polymath/issues) * [PyPi](https://pypi.org/project/rms-polymath) diff --git a/docs/Makefile b/docs/Makefile index d4bb2cb..701925f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,8 +3,16 @@ # You can set these variables from the command line, and also # from the environment for the first two. +# +# SPHINXBUILD points into the project virtualenv rather than relying on PATH, because a +# sphinx-build found on PATH is often an older installation. The docs need Sphinx 9 (see +# the docs extra in pyproject.toml); on earlier versions every polymath.typedefs alias +# reference goes unresolved and the -W build fails. Override VENV (as +# scripts/run-all-checks.sh does) to build against another environment. +MAKEFILE_DIR := $(dir $(lastword $(MAKEFILE_LIST))) +VENV ?= $(MAKEFILE_DIR)../venv SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build +SPHINXBUILD ?= $(VENV)/bin/sphinx-build SOURCEDIR = . BUILDDIR = _build diff --git a/docs/conf.py b/docs/conf.py index 3b03e50..445bdcb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,6 +55,14 @@ # The suffix(es) of source filenames. source_suffix = ['.rst', '.md'] +# The docstrings wrap variable names in single backticks. Napoleon renders the name of +# each entry in a `Parameters:` block in bold, so the default role must be `strong` for a +# mention of that same name in the surrounding prose to match it. Double backticks mark +# code expressions, and italics mark math symbols that are not variable names, such as +# *x*-axis. An API symbol that should link to its own entry carries an explicit role +# instead. +default_role = 'strong' + # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. @@ -79,7 +87,7 @@ napoleon_use_admonition_for_examples = False napoleon_use_admonition_for_notes = False napoleon_use_admonition_for_references = False -napoleon_use_ivar = False +napoleon_use_ivar = True napoleon_use_param = True napoleon_use_rtype = True napoleon_preprocess_types = False @@ -107,15 +115,9 @@ # Napoleon splits a type such as "(bool, optional)" on the comma and looks up each # piece, so the trailing "optional" of every optional parameter arrives here. (r'py:class', r'optional'), - # Anything NumPy can turn into an array: a nested sequence, a scalar, an ndarray or - # another PolyMath object. There is no single class that expresses it. - (r'py:class', r'array-like'), - # A single number, as opposed to an array of them. - (r'py:class', r'scalar'), - # Anything convertible to a Vector, in the same sense as "array-like". - (r'py:class', r'vector-like'), - # Anything the surrounding class can convert into itself. - (r'py:class', r'convertible'), + # The sentinel a binary operator returns to defer to the other operand. It is a + # builtin constant rather than a class, so a py:class lookup cannot match it. + (r'py:class', r'NotImplemented'), ] # MyST-Parser settings diff --git a/docs/dev_guide/dev_guide.rst b/docs/dev_guide/dev_guide.rst new file mode 100644 index 0000000..6a5b084 --- /dev/null +++ b/docs/dev_guide/dev_guide.rst @@ -0,0 +1,21 @@ +=============== +Developer Guide +=============== + +This guide is for people who modify, extend, test, or release PolyMath. It explains how +the code is organized, how the pieces cooperate, and how to add a feature or a subclass +without breaking the contracts the rest of the package relies on. + +.. toctree:: + :maxdepth: 3 + + dev_guide_introduction + dev_guide_layout + dev_guide_environment + dev_guide_architecture + dev_guide_extensions + dev_guide_subclasses + dev_guide_typing + dev_guide_extending + dev_guide_conventions + dev_guide_internal_api diff --git a/docs/dev_guide/dev_guide_architecture.rst b/docs/dev_guide/dev_guide_architecture.rst new file mode 100644 index 0000000..fe297ab --- /dev/null +++ b/docs/dev_guide/dev_guide_architecture.rst @@ -0,0 +1,346 @@ +============ +Architecture +============ + +Class Hierarchy +=============== + +.. mermaid:: + + classDiagram + class Qube { + +values + +mask + +derivs + +unit_ + +shape + +item + +numer + +denom + +readonly + +__init__(arg, mask, derivs, unit, nrank, drank, example, default) + +clone() + +zeros() + +ones() + +filled() + +_new_from_parts() + +_set_values() + +_set_mask() + } + class Unit { + +exponents + +triple + +name + +as_unit() + +from_this() + +into_this() + } + class Scalar { + +as_scalar() + +sin() + +sqrt() + +max() + } + class Boolean { + +as_boolean() + +as_index() + } + class Vector { + +as_vector() + +dot() + +cross() + +norm() + +to_scalars() + } + class Pair { + +as_pair() + +swapxy() + +angle() + } + class Vector3 { + +as_vector3() + +from_ra_dec_length() + +spin() + } + class Quaternion { + +as_quaternion() + +to_matrix3() + +conj() + } + class Polynomial { + +as_polynomial() + +eval() + +roots() + } + class Matrix { + +as_matrix() + +inverse() + +solve() + } + class Matrix3 { + +as_matrix3() + +x_rotation() + +rotate() + } + Qube <|-- Scalar + Scalar <|-- Boolean + Qube <|-- Vector + Vector <|-- Pair + Vector <|-- Vector3 + Vector <|-- Quaternion + Vector <|-- Polynomial + Qube <|-- Matrix + Matrix <|-- Matrix3 + Qube o-- "0..1" Unit : _unit + Qube o-- "0..*" Qube : _derivs + +None of the classes is abstract. :class:`~polymath.Qube` is fully functional on its own +and can be instantiated with any numerator rank, which is how a few operations build +intermediate results; it is left out of the public constructors only because a concrete +class documents intent better. Every subclass is a specialization that fixes some of the +class constants described below and adds methods. + +The three lineages differ in the rank of their numerator. :class:`~polymath.Scalar` and +its subclass :class:`~polymath.Boolean` have rank 0. :class:`~polymath.Vector` and its +four subclasses have rank 1, with :class:`~polymath.Pair`, :class:`~polymath.Vector3`, +and :class:`~polymath.Quaternion` fixing the length at 2, 3, and 4, and +:class:`~polymath.Polynomial` leaving it free but reinterpreting the components as +coefficients. :class:`~polymath.Matrix` and its subclass :class:`~polymath.Matrix3` have +rank 2, with :class:`~polymath.Matrix3` fixing the shape at 3x3 and adding the rotation +methods. :class:`~polymath.Unit` stands apart: it is an ordinary class that a +:class:`~polymath.Qube` refers to, and it has no shape, mask, or derivatives. + +Anatomy of a Qube +================= + +Every object is a small bundle of attributes, all set by the constructor and read +directly by the extension functions. The table lists them in the order the constructor +assigns them, which is also the order in the class-level tuple that the copying methods +use to transfer them. Keep the two in step when adding an attribute. + +.. list-table:: + :header-rows: 1 + :widths: 24 76 + + * - Attribute + - Contents and invariant + * - ``_values`` + - A NumPy array, or a Python ``float``, ``int``, or ``bool`` when the shape and item + are both empty. Never a zero-dimensional array and never a NumPy scalar type; + the construction path reduces both. Always in standard units. + * - ``_mask`` + - A Python ``bool``, or a boolean array whose shape equals ``_shape``. Never an + array with item axes. + * - ``_is_array``, ``_is_scalar`` + - Whether ``_values`` is an array. Exactly one is True. + * - ``_shape``, ``_ndims``, ``_size`` + - The leading axes, their count, and their product. + * - ``_rank``, ``_nrank``, ``_drank`` + - The number of item axes, and its split into numerator and denominator axes. + * - ``_item``, ``_numer``, ``_denom`` + - The item shape and its split. ``_item == _numer + _denom``, and + ``np.shape(_values) == _shape + _item``. + * - ``_isize``, ``_nsize``, ``_dsize`` + - The products of the three item shapes. + * - ``_unit`` + - A :class:`~polymath.Unit`, or None for a unitless object. A unit of + :attr:`~polymath.Unit.UNITLESS` is stored as None. + * - ``_readonly`` + - True if the values array is flagged non-writable. The mask array is flagged the + same way whenever the object is read-only. + * - ``_truth_if_any``, ``_truth_if_all`` + - Flags consulted when the object is used in a boolean context. + * - ``_default`` + - The value stored in masked elements when an object is unpickled or unshrunk. It + has the item shape and the object's data type. + * - ``_derivs`` + - A dictionary of derivatives, each a :class:`~polymath.Qube` broadcastable to + ``_shape``. Each is also exposed as an attribute named ``d_d`` plus its key. + * - ``_cache`` + - Values derived from the object and cleared whenever it changes. See + `Caching and Shrinking`_. + * - ``_added_attrs`` + - A frozenset of the names added by :meth:`~polymath.Qube.add_attr`. The + class-level default is shared and never modified in place. + * - ``_pickle_digits``, ``_pickle_reference`` + - Present only once :meth:`~polymath.Qube.set_pickle_digits` has been called. + +Two further invariants apply to the class as a whole. An object is not hashable, because +it compares by value and is mutable; the binding module sets the hash to None explicitly, +because defining the equality operator after the class body would otherwise leave the +default identity hash in place. And nothing is synchronized: reading an object from +several threads is safe, but modifying one while another thread reads it is not, and +neither is changing a global setting once other threads are running. + +Class Constants +=============== + +A subclass declares what it accepts through class constants, which the constructor and +the conversion functions consult. Each subclass module sets all of them explicitly, one +per line with a trailing comment, even where the value matches the base class. + +.. list-table:: + :header-rows: 1 + :widths: 24 76 + + * - Constant + - Meaning + * - ``_NRANK`` + - The number of numerator axes, or None to leave it unconstrained. The base class + uses None. + * - ``_NUMER`` + - The numerator shape, or None to leave it unconstrained. :class:`~polymath.Vector` + and :class:`~polymath.Matrix` fix the rank but not the shape. + * - ``_FLOATS_OK``, ``_INTS_OK``, ``_BOOLS_OK`` + - Which data types the class may hold. Integer input to a class with only + ``_FLOATS_OK`` is converted; boolean input to a class without ``_BOOLS_OK`` is an + error. + * - ``_UNITS_OK`` + - Whether the class may carry a unit. False for :class:`~polymath.Boolean`, + :class:`~polymath.Matrix3`, and :class:`~polymath.Quaternion`. + * - ``_DERIVS_OK`` + - Whether the class may carry derivatives or a denominator. False for + :class:`~polymath.Boolean`. + * - ``_DEFAULT_VALUE`` + - The value for masked elements of an object with no denominator. Absent from the + base class, which then uses ones of the item shape. + * - ``_DERIV_CLASS`` + - The class of a derivative of this class, or None when a derivative has the same + class as the object. :class:`~polymath.Matrix3` names :class:`~polymath.Matrix`, + because the derivative of a rotation matrix is not a rotation matrix. + +Three more constants on the base class are switches for testing. ``_DISABLE_CACHE`` +bypasses the cache, ``_DISABLE_SHRINKING`` turns :meth:`~polymath.Qube.shrink` and +:meth:`~polymath.Qube.unshrink` into no-ops, and ``_IGNORE_UNSHRUNK_AS_CACHED`` makes +:meth:`~polymath.Qube.unshrink` ignore its cached result. A calculation must give the same +answer with any combination of these set. ``__array_priority__`` is set so that NumPy +defers to PolyMath's operators when an array appears on the left of an expression. + +Construction Paths +================== + +There are three ways an object comes into being, and choosing the right one is most of +what writing an operation correctly involves. + +**The constructor** :meth:`~polymath.Qube.__init__` is the public path. It accepts any +array-like argument, infers the split between shape and item from the class constants +and the ``nrank`` and ``drank`` arguments, coerces the data type, validates the mask, +checks the unit and derivatives against the class constants, and installs the +derivatives. Its ``example`` argument copies the mask, unit, ranks, and default from +another object wherever they were not given explicitly, and its ``op`` argument names the +operation for error messages. This path does the most work and the most checking, so +operations use it only when the input is not yet known to be valid. + +**The fast path** ``_new_from_parts`` is the internal counterpart. It takes a values +array whose data type is already acceptable, a mask already broadcastable to the leading +shape, and the ranks, and it assigns every attribute directly without checks. It never +copies derivatives; the caller inserts them afterward. Its ``example`` argument lets it +reuse the size products and the default from an operand whose item shape and data type +carried through, which is purely an optimization. Every arithmetic operator uses this +path, so an error in a caller's bookkeeping surfaces as a corrupt object rather than an +exception. Use it only when the caller has computed the result itself and can vouch for +every part. + +**Cloning** produces a shallow copy that shares the values array. :meth:`~polymath.Qube.clone` +carries the attributes added by :meth:`~polymath.Qube.add_attr`, and it is the right basis +for a result that describes the same quantity, such as a reshaped or remasked view. The +private variant ``_clone_new_values`` omits those attributes, and it is the right basis +for a result that is about to be given different values, such as a negation. Both +optionally clone the derivatives, and both start with an empty cache unless asked to +retain it. + +After construction, two low-level methods modify an object in place. ``_set_values`` +replaces the values array, optionally only where an antimask is True, and re-derives the +read-only state from the array's flags. ``_set_mask`` replaces the mask and preserves the +read-only state. Both clear the cache. The class methods :meth:`~polymath.Qube.zeros`, +:meth:`~polymath.Qube.ones`, and :meth:`~polymath.Qube.filled` build constant objects on +top of the constructor, and ``_default_for`` computes the default value for a class, item +shape, and data type. + +Extension Binding +================= + +The module ``src/polymath/qube.py`` defines the class, and the package +``src/polymath/extensions/`` defines almost everything the class can do. Each extension +module is a collection of plain functions whose first parameter is ``self``, and the file +``src/polymath/extensions/__init__.py`` assigns each one onto :class:`~polymath.Qube` as +an attribute, so that it becomes a method. The assignments are grouped by module and +listed in the order the functions appear in the module, which makes the binding file a +table of contents for the class. + +Three rules follow from this arrangement. + +1. **Import order.** ``src/polymath/__init__.py`` imports the extensions package before + any subclass module. Each subclass builds its read-only constants as it loads, and + constructing those objects calls bound methods such as + :meth:`~polymath.Qube.as_readonly`, which do not exist until the binding has run. +2. **No subclass imports in extensions.** An extension module cannot import a subclass at + module level without creating an import cycle, because every subclass imports + :class:`~polymath.Qube`. Instead, each subclass module registers itself on the base + class at the bottom of the file, as ``Qube._SCALAR_CLASS``, ``Qube._BOOLEAN_CLASS``, + ``Qube._VECTOR_CLASS``, ``Qube._PAIR_CLASS``, ``Qube._VECTOR3_CLASS``, + ``Qube._QUATERNION_CLASS``, ``Qube._MATRIX_CLASS``, or ``Qube._MATRIX3_CLASS``, and + the extension functions reach the subclasses through those attributes at call time. +3. **Class methods are wrapped at the binding site.** A ``@staticmethod`` or + ``@property`` can be written at module level and bound directly, but a module-level + ``@classmethod`` is not a function and stubtest rejects it. Write the plain function + with the class as its first parameter and wrap it in ``classmethod`` in the binding + file, as the data-type helpers in the dtypes module are. + +Operator Dispatch +================= + +The arithmetic operators live in the math operations module. Each binary operator first +converts its right operand into something compatible with the left one, which is where a +Python number, a NumPy array, or a nested sequence becomes a :class:`~polymath.Qube`, and +then dispatches on the combination of classes: a number or a :class:`~polymath.Scalar` +operand scales the other operand item by item, while two operands with item axes must +have matching numerators. Each operator computes the values with NumPy, combines the two +masks, checks or combines the units, builds the result with the fast construction path, +and then propagates the derivatives with a helper of its own, such as the one that +applies the product rule for multiplication. The derivative helpers are where the +denominator axes matter: a derivative may have a denominator that its parent lacks, and +the helper must broadcast the parent's values against it correctly. + +Subclasses override an operator only to change its meaning. :class:`~polymath.Matrix3` +and :class:`~polymath.Quaternion` redefine multiplication as rotation composition and +the quaternion product, :class:`~polymath.Polynomial` redefines the arithmetic operators +as polynomial arithmetic, and :class:`~polymath.Boolean` redefines the arithmetic +operators to return a :class:`~polymath.Scalar`, since the sum of two truth values is a +count. + +The error helpers in the errors module phrase every message the same way, naming the +operation, the classes involved, and the offending shapes. Use them rather than raising +directly, so that error messages stay consistent. + +Caching and Shrinking +===================== + +Each object carries a dictionary of cached results, cleared by every method that changes +the object. The keys in use are the corners of the unmasked region and the slice that +selects it, both used by shrinking; the copy without derivatives returned by +:attr:`~polymath.Qube.wod`; and the shrunken and unshrunken forms of the object, which +let a sequence of operations on a shrunken object reuse one shrink and one unshrink. +A method that returns a copy sharing the values array may retain the cache, but must +drop the entries that describe a different object. + +Shrinking exists because the objects that describe an image are often mostly masked. +:meth:`~polymath.Qube.shrink` finds the smallest hypercube containing the unmasked +elements, from the cached corners, slices it out, and then flattens it to the elements +selected by the antimask, returning a read-only one-dimensional object. +:meth:`~polymath.Qube.unshrink` reverses the process. Every operation must give the same +result on shrunken and unshrunken operands, which the ``_DISABLE_SHRINKING`` switch +exists to verify. + +The Unit Class +============== + +:class:`~polymath.Unit` records three integer exponents on distance, time, and angle, and +a triple of integers giving the exact factor that converts a value in the unit into the +standard units of kilometers, seconds, and radians, as a numerator, a denominator, and a +power of pi. Because the factor is exact, converting a value out of a unit and back +loses nothing. The values inside a :class:`~polymath.Qube` are always in standard units, +so a unit affects only construction, display, and compatibility checks; arithmetic on +units, which builds compound units, is implemented on the class itself. The class +constants for the common units are built at the bottom of the module, and a registry +keyed by name serves :meth:`~polymath.Unit.as_unit`. diff --git a/docs/dev_guide/dev_guide_conventions.rst b/docs/dev_guide/dev_guide_conventions.rst new file mode 100644 index 0000000..c89ba35 --- /dev/null +++ b/docs/dev_guide/dev_guide_conventions.rst @@ -0,0 +1,81 @@ +================== +Coding Conventions +================== + +The authoritative rules live in the repository, in ``CLAUDE.md`` and under +``.claude/rules/``. Those files are written to guide AI-assisted development, but they +apply to every contributor, and the checks enforce most of them. This chapter summarizes +the rules a developer is most likely to trip over. + +Python Style +============ + +* Ruff is the linter of record for every rule it implements, with the rule set in + ``pyproject.toml``. Each disabled rule has its reason beside it; read the reason before + re-enabling one. Do not disable the ``A`` (builtins) or ``N`` (naming) categories. +* Ruff has no rule for continuation-line indentation, so flake8 checks codes E12x and + E13x, reading the per-file exemptions in ``.flake8``. Those exemptions cover deliberate + column alignment; read the comment before removing one. +* The maximum line length is 90 characters. Test files are exempt. +* Use single quotes. +* The code base aligns assignments and imports in columns on purpose, and the + whitespace rules that would object are switched off. Do not reformat that alignment + away, and match the style of the surrounding file. +* At most five positional parameters; the rest are keyword-only after ``*``. +* No unicode smart quotes, em-dashes, or arrows inside ``.py`` files. +* No type annotations under ``src/``, except the return annotation of a property. See + :doc:`dev_guide_typing`. +* Make the minimal change the task requires. + +Docstrings +========== + +Every module, class, function, and method has a docstring in Google style, using +``Parameters:`` rather than ``Args:``, with ``Returns:`` and ``Raises:`` where they apply, +wrapped to 90 characters. A docstring must be detailed enough that a black-box test can +be written from it alone. It describes observable behavior only, never implementation +details, change history, backward compatibility, or an issue number. + +Tests +===== + +* The suite is pytest throughout: module-level ``test_*`` functions, plain ``assert``, + fixtures, ``pytest.raises`` with ``match=``, and ``pytest.mark.parametrize``. No + ``unittest.TestCase``. +* Every test function and method is annotated, including ``-> None``. +* Each test is independent. It seeds NumPy's random generator itself and defines every + value it needs, so that it passes alone and in any order under parallel execution. +* One behavior per test function where practical, so a failure names what broke. One + condition per ``assert``, on an exact expected value; ``pytest.approx`` for floats. +* Any warning raised during a test fails it. Add an ``ignore::`` entry only for a warning + from third-party code, with a comment saying why. +* Register any custom marker in ``pyproject.toml`` before using it. +* Coverage stays at or above 90 percent over the whole suite. + +Documentation +============= + +* Narrative documentation is reStructuredText under ``docs/``; Markdown is only for the + files that must also render on GitHub. +* Builds are warning-as-error and nitpicky everywhere. Every API symbol named in prose + uses a Sphinx role; a bare CamelCase name or an inline literal is a violation, and an + inline literal is reserved for file paths, keys, and shell tokens. +* Cross-references to third-party objects use the spelling their documentation exports, + such as ``numpy.ndarray`` rather than ``np.ndarray``. Never add a nitpick exemption + for a symbol this project owns. +* American spelling, one space after a sentence-ending period, and no time-anchored + words such as "new", "legacy", or "now". +* Any code change updates the affected docstrings, guide chapters, and README in the + same change. + +Repository Etiquette +==================== + +* Branch names follow ``__``. +* Commit subjects are plain capitalized imperative sentences with no type prefix and no + trailing period. Pull requests are squash-merged, which appends the pull request + number. +* Dependencies go in ``pyproject.toml`` only, with minimum version constraints and never + exact pins. ``requirements.txt`` contains just ``-e .``. +* Never commit ``build/``, ``.coverage``, ``.pytest_cache/``, ``htmlcov/``, or + ``src/rms_polymath.egg-info/``, and never hand-edit ``src/polymath/_version.py``. diff --git a/docs/dev_guide/dev_guide_environment.rst b/docs/dev_guide/dev_guide_environment.rst new file mode 100644 index 0000000..8e7721f --- /dev/null +++ b/docs/dev_guide/dev_guide_environment.rst @@ -0,0 +1,212 @@ +======================= +Development Environment +======================= + +Getting a Working Checkout +========================== + +Clone the repository and run the bootstrap script, which creates a virtual environment +at ``./venv`` and installs the package in editable mode with the ``dev`` extra. The +``dev`` extra includes the ``docs`` extra, so one command installs everything the checks +need. The script refuses an interpreter older than Python 3.11 and is safe to rerun. + +.. code-block:: sh + + git clone https://github.com/SETI/rms-polymath.git + cd rms-polymath + ./scripts/setup-venv.sh + source venv/bin/activate + +Pass ``--python`` to choose an interpreter and ``--recreate`` to rebuild the environment +from scratch: + +.. code-block:: sh + + ./scripts/setup-venv.sh --python python3.13 --recreate + +Never install into the system Python. If you prefer to manage the environment yourself, +the equivalent of the script is: + +.. code-block:: sh + + python3 -m venv venv + source venv/bin/activate + pip install -e ".[dev]" + +Environment Variables +===================== + +The package itself reads no environment variables. The scripts read these: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Variable + - Meaning + * - ``VENV`` or ``VENV_PATH`` + - The virtual environment the scripts activate. Default: ``./venv``. The + ``Makefile`` in ``docs/`` honors ``VENV`` as well, so that it finds the Sphinx + installed there rather than one on the search path. + * - ``ENABLE_`` + - Per-check switches read by the check script, such as ``ENABLE_MYPY=true``. The + defaults define the set of checks the repository has opted into; see + `Running the Checks`_. + * - ``CLEANUP_GRACE_PERIOD`` + - Seconds the check script waits for a check to stop after an interrupt before + killing it. Default: 5. + +Smoke Test +========== + +The package has no command-line entry points. Confirm that the editable install works by +importing it and performing an operation that exercises the extension binding: + +.. code-block:: sh + + python -c "import polymath; print(polymath.__version__); print(polymath.Scalar([1., 2.]) * 2)" + +The version is derived from the git history by ``setuptools_scm`` and written to +``src/polymath/_version.py`` at install time, so a checkout that is not a git repository +reports ``Version unspecified``. + +Running the Tests +================= + +The suite is pytest throughout. The options in ``pyproject.toml`` apply to every +invocation: tests run in parallel with ``pytest-xdist``, coverage is collected for +``src/polymath``, unregistered markers and misspelled options are errors, and every +warning raised during a test fails it. + +.. code-block:: sh + + pytest # the whole suite, in parallel, with coverage + pytest tests/test_pair_as_pair.py # one file + pytest -k swapxy # tests whose names match + pytest -n 0 tests/test_qube_shrink.py # serially, which is easier to debug + coverage report -m # missing lines, after a run + pytest --cov-report=html # writes htmlcov/index.html + +Coverage must stay at or above 90 percent, measured over the whole suite with branch +coverage on; the run fails below that. There are no slow or environment-dependent tiers +and no registered markers, so a bare ``pytest`` runs everything. Tests must be independent +and order-agnostic, because they run in parallel: each function seeds NumPy's random +generator itself and defines every value it uses. + +The check script runs pytest with ``--dist loadscope``, which keeps each test module on +one worker. Use the same flag when reproducing a failure that the script reports. + +Running the Checks +================== + +``scripts/run-all-checks.sh`` is the single source of truth for which checks must pass. +CI runs exactly that set, no more and no less, so passing the script locally means +passing CI. Run it after every change. + +.. code-block:: sh + + ./scripts/run-all-checks.sh # everything, in parallel + ./scripts/run-all-checks.sh -s # everything, sequentially, easier to read + ./scripts/run-all-checks.sh -c # code checks only + ./scripts/run-all-checks.sh -d # Sphinx and Markdown only + ./scripts/run-all-checks.sh --pytest # one check; combine flags as needed + +The checks it enables by default are: + +.. list-table:: + :header-rows: 1 + :widths: 22 30 48 + + * - Check + - Flag + - What it enforces + * - ruff + - ``--ruff-check`` + - The linter of record, for every rule it implements. The rule set and the + deliberate exemptions are in ``pyproject.toml``. + * - flake8 + - ``--flake8-cont`` + - Continuation-line indentation only (codes E12x and E13x), which ruff does not + implement. The per-file exemptions in ``.flake8`` are authoritative for these + codes alone. + * - pytest + - ``--pytest`` + - The test suite and the coverage floor. + * - pyroma + - ``--pyroma`` + - Packaging metadata completeness. + * - stubtest + - ``--stubtest`` + - The two stubs, ``__init__.pyi`` and ``typedefs.pyi``, match the runtime API. See + :doc:`dev_guide_typing`. + * - Sphinx + - ``--sphinx`` + - The documentation builds with warnings as errors and with nitpicky + cross-reference checking. + * - PyMarkdown + - ``--pymarkdown`` + - Markdown style for ``docs/``, ``.claude/``, ``README.md``, and ``CONTRIBUTING.md``. + +Four more checks are wired in but disabled by default: ``ruff format --check``, mypy, +bandit, and vulture. Leave them disabled. In particular, never run mypy on ``src/``: the +modules there are deliberately unannotated, so it would report meaningless errors. When +the ``--mypy`` check is enabled it runs against ``tests/`` only, which are fully +annotated. Run it by hand the same way: + +.. code-block:: sh + + MYPYPATH=src mypy tests + +Building the Documentation +========================== + +The documentation builds with ``-W``, so any warning is an error, and ``docs/conf.py`` +sets nitpicky mode, so a cross-reference with no target is an error too. Both apply in +the check script, in CI, and on ReadTheDocs. Sphinx 9 or later is required, because +earlier versions cannot resolve the references to the aliases in +:mod:`polymath.typedefs`. + +.. code-block:: sh + + ./scripts/run-all-checks.sh --sphinx # build only + ./scripts/read-docs.sh # build, then open in a browser + +Continuous Integration +====================== + +Four GitHub Actions workflows live in ``.github/workflows/``. + +* ``run-tests.yml`` runs on every pull request against ``main``, on every push to + ``main``, weekly, and on demand. Its lint job runs ruff, flake8, pyroma, stubtest, + Sphinx, and PyMarkdown on Python 3.13, which is the check script's default set minus + pytest. Its test job runs pytest with coverage on Ubuntu, macOS, and Windows for each + of Python 3.11, 3.12, and 3.13, and uploads coverage to Codecov from one cell of the + matrix. +* ``audit.yml`` runs ``pip-audit`` weekly and on demand. It is deliberately not part of + the pull request gate, because a vulnerability advisory can appear without any change + to the repository. +* ``publish_to_pypi.yml`` builds and validates the distribution and uploads it to PyPI + when a GitHub Release is published. +* ``publish_to_test_pypi.yml`` does the same for Test PyPI, on demand. + +ReadTheDocs builds the documentation from ``.readthedocs.yaml``, installing the package +with the ``docs`` extra on Python 3.12. + +Releasing +========= + +Versions come from git tags through ``setuptools_scm``; never edit +``src/polymath/_version.py`` by hand. To release, tag the commit on ``main`` with the +version, push the tag, and create a GitHub Release from it. Publishing the release +triggers the upload to PyPI. A build from a commit that is not tagged carries a +development version derived from the most recent tag. + +Contributing Changes +==================== + +Work on a branch named ``__``, such as ``rf_251204_mixins``. +Commit subjects are plain capitalized imperative sentences with no type prefix and no +trailing period. Every pull request must pass the full check set, and pull requests are +squash-merged onto ``main``, which appends the pull request number to the subject. +:doc:`/contributing` covers reporting bugs, proposing enhancements, and the legal terms +of a contribution. diff --git a/docs/dev_guide/dev_guide_extending.rst b/docs/dev_guide/dev_guide_extending.rst new file mode 100644 index 0000000..45f7fa5 --- /dev/null +++ b/docs/dev_guide/dev_guide_extending.rst @@ -0,0 +1,268 @@ +==================== +Extending the System +==================== + +This chapter gives a step-by-step recipe for each of the three kinds of addition: a +method available on every class, a method on one subclass, and a subclass of its own. +Each recipe ends with the same checklist, because every addition touches the same set of +files: the implementation, the stub, the tests, and the documentation. The contracts the +recipes rely on are described in :doc:`dev_guide_architecture`, +:doc:`dev_guide_extensions`, and :doc:`dev_guide_subclasses`; this chapter does not +repeat them. + +Adding a Method to Every Class +============================== + +A method that applies to any :class:`~polymath.Qube` is written as a plain function in +an extension module and bound onto the class. The example adds a method that repeats an +object along a leading axis. + +1. **Choose the module.** Put the function in the extension module that owns the + concern; a method that rearranges the leading axes belongs in + ``src/polymath/extensions/shaper.py``. Create a module only for a concern none of the + existing ones covers, and give it the same header, docstring, and ``__all__`` as its + neighbors. + +2. **Write the function.** Its first parameter is ``self``. Read the private attributes + directly, build the result with the fast construction path, and propagate the + derivatives yourself. + + .. code-block:: python + + def tile(self, reps, *, recursive=True): + """Repeat this object along a new leading axis. + + Parameters: + reps (int): The number of repetitions. + recursive (bool, optional): True to tile the derivatives as well; False to + return an object without derivatives. + + Returns: + Qube: An object of shape ``(reps,) + self.shape``, sharing no memory with + this one. + + Raises: + ValueError: If `reps` is less than one. + """ + + if reps < 1: + raise ValueError(f'invalid repetition count for ' + f'{type(self).__name__}.tile(): {reps}') + + values = np.repeat(np.asarray(self._values)[np.newaxis], reps, axis=0) + mask = self._mask + if isinstance(mask, np.ndarray): + mask = np.repeat(mask[np.newaxis], reps, axis=0) + + obj = type(self)._new_from_parts(values, mask, nrank=self._nrank, + drank=self._drank, unit=self._unit, + example=self) + + if recursive: + for key, deriv in self._derivs.items(): + obj.insert_deriv(key, deriv.tile(reps, recursive=False)) + + return obj + + Add the name to the module's ``__all__``. Note what the function does not do: it + does not import a subclass, it does not scale the values by the unit, and it does not + modify ``self``. + +3. **Bind it.** Add one line to ``src/polymath/extensions/__init__.py``, in the block for + the module, keeping the aligned assignment style: + + .. code-block:: python + + Qube.tile = shaper.tile + +4. **Declare it in the stub.** Add the signature under :class:`~polymath.Qube` in + ``src/polymath/__init__.pyi``, in alphabetical order, taking the types from the + docstring: + + .. code-block:: python + + def tile(self, reps: int, *, recursive: bool = ...) -> Qube: ... + +5. **Test it.** Add tests to the file for the class and topic, here + ``tests/test_qube_reshaping.py``, or start a new file named the same way. Each test + is annotated, independent, and asserts exact values: + + .. code-block:: python + + def test_tile_repeats_values_and_mask() -> None: + """Tiling repeats the values and the mask along a new leading axis.""" + + np.random.seed(1234) + x = Scalar(np.random.randn(3), mask=[False, True, False]) + tiled = x.tile(2) + assert tiled.shape == (2, 3) + assert np.all(tiled.values == np.stack([x.values, x.values])) + assert np.all(tiled.mask == np.stack([x.mask, x.mask])) + + + def test_tile_propagates_derivatives() -> None: + """Derivatives are tiled alongside the values unless recursive is False.""" + + x = Vector3([1., 2., 3.]) + x.insert_deriv('t', Vector3([0., 0., 1.])) + assert x.tile(4).d_dt.shape == (4,) + assert not hasattr(x.tile(4, recursive=False), 'd_dt') + +6. **Document it.** The method appears in :doc:`/module` automatically through the + docstring. Mention it in the relevant chapter of the user guide, and in the module's + entry in :doc:`dev_guide_extensions` if it changes what the module is responsible + for. + +Adding a Method to One Subclass +=============================== + +A method that makes sense for one class only, such as a new coordinate conversion for +:class:`~polymath.Vector3`, is written in the subclass module as an ordinary method. + +1. Write the method in the class body of ``src/polymath/.py``, with a docstring + and the ``recursive`` keyword if it produces a value with derivatives. A method that + the subclass shares with a sibling belongs on their common parent instead. +2. Add the signature to the class in ``src/polymath/__init__.pyi``, in alphabetical + order. Never create a stub beside the module: the only supported import is from the + package, and a per-module stub would make an import from the module look supported. +3. If the method changes the meaning of an operator inherited from + :class:`~polymath.Qube`, say so in the docstring with the sentence the existing + overrides use, and give the override the same signature as the base method, adding + ``# type: ignore[override]`` in the stub only when the signature must differ. +4. Add tests to ``tests/test__.py``. +5. Mention the method in the user guide chapter for the class. + +Adding a Subclass +================= + +A subclass fixes the constraints on an item and adds methods. The example defines a +4-vector; the steps are the same for a class with any item shape. + +1. **Create the module** ``src/polymath/vector4.py`` following the pattern of the + sibling modules. Set all seven constraint constants, define the converter, build the + constants after the class body, and register the class. + + .. code-block:: python + + ########################################################################################## + # polymath/vector4.py: Vector4 subclass of PolyMath Vector + ########################################################################################## + """The :class:`~polymath.Vector4` subclass, representing 4-vectors.""" + + import numpy as np + import numbers + + from polymath.qube import Qube + from polymath.vector import Vector + + __all__ = ['Vector4'] + + + class Vector4(Vector): + """Represent 4-vectors in the PolyMath framework.""" + + _NRANK = 1 # The number of numerator axes. + _NUMER = (4,) # Shape of the numerator. + _FLOATS_OK = True # True to allow floating-point numbers. + _INTS_OK = True # True to allow integers. + _BOOLS_OK = False # True to allow booleans. + _UNITS_OK = True # True to allow units; False to disallow them. + _DERIVS_OK = True # True to allow derivatives and denominators; False to disallow. + _DEFAULT_VALUE = np.array([1, 1, 1, 1]) + + @staticmethod + def as_vector4(arg, *, recursive=True): + """Convert the argument to Vector4 if possible. + + Parameters: + arg (Any): The object to convert. + recursive (bool, optional): If True, derivatives are also converted. + + Returns: + Vector4: The converted object. + + Notes: + A single number is repeated in all four components. + """ + + if isinstance(arg, Vector4): + return arg if recursive else arg.wod + + if isinstance(arg, Qube): + if arg._numer in ((1, 4), (4, 1)): + return arg.flatten_numer(classes=Vector4, recursive=recursive) + if arg.rank > 1 and arg._numer[0] == 4: + arg = arg.split_items(1, classes=Vector4) + arg = Vector4(arg._values, arg._mask, example=arg) + return arg if recursive else arg.wod + + if isinstance(arg, numbers.Real): + return Vector4((arg, arg, arg, arg)) + + return Vector4(arg) + + + # Read-only constants, built after the class exists so that the bound methods are + # available, and shared safely because they cannot be modified + Vector4.ZERO = Vector4((0., 0., 0., 0.)).as_readonly() + Vector4.ONES = Vector4((1., 1., 1., 1.)).as_readonly() + Vector4.MASKED = Vector4((1, 1, 1, 1), True).as_readonly() + + # Register the class so that the extension modules can reach it without importing it + Qube._VECTOR4_CLASS = Vector4 + + ########################################################################################## + + Decide the derivative class. If a derivative of the class satisfies the same + constraints, leave the inherited None; if it does not, as for a rotation matrix, + name the more general class. Decide the default value, which masked elements take on + after unpickling, and choose one that does not break arithmetic. + +2. **Export it.** In ``src/polymath/__init__.py``, add the import to the block of + subclass imports, which comes after the import of the extensions package, and add + the name to ``__all__``. Then add the module to the ``exclude`` list and the override + list under ``[tool.mypy]`` in ``pyproject.toml`` and to ``.stubtest-allowlist``, so + that stubtest treats it like its siblings; :doc:`dev_guide_typing` explains why. + +3. **Declare it in the stub.** In ``src/polymath/__init__.pyi``, add the name to + ``__all__`` and add the class after its parent, following the sibling classes: the + constants typed as the class, and every public method in alphabetical order. Do not + create ``vector4.pyi``. + + .. code-block:: python + + class Vector4(Vector): + MASKED: Vector4 + ONES: Vector4 + ZERO: Vector4 + @staticmethod + def as_vector4(arg: Any, *, recursive: bool = ...) -> Vector4: ... + +4. **Add a type alias**, if downstream code will annotate parameters that accept the + class. In ``src/polymath/typedefs.py``, define the array alias for the item shape, + define the public alias with a docstring, and add its name to ``__all__``; then make + the same additions to ``src/polymath/typedefs.pyi``. + +5. **Test it.** Create ``tests/test_vector4_basic.py`` and cover construction from each + kind of input, rejection of the wrong item shape with ``pytest.raises`` and a + ``match=``, the converter's every branch, the constants, and any methods. Run the full + suite, because the change to the package namespace affects every test. + +6. **Document it.** The class appears in :doc:`/module` through the ``__all__`` of the + package. Add a module entry to :doc:`dev_guide_internal_api`, a row to the class table + in the user guide introduction, a paragraph to :doc:`dev_guide_subclasses`, and a + bullet to the feature list in ``README.md``. + +Checklist +========= + +Before opening a pull request for any of the above: + +* The docstring is complete enough to write a black-box test from, and uses + ``Parameters:``. +* The stub matches the implementation, and stubtest passes. +* The tests are annotated, independent, and assert exact values, and coverage has not + dropped. +* The user guide and this guide say what changed, and the documentation builds with no + warnings. +* ``./scripts/run-all-checks.sh`` passes. diff --git a/docs/dev_guide/dev_guide_extensions.rst b/docs/dev_guide/dev_guide_extensions.rst new file mode 100644 index 0000000..1cd1862 --- /dev/null +++ b/docs/dev_guide/dev_guide_extensions.rst @@ -0,0 +1,174 @@ +===================== +The Extension Modules +===================== + +Overview +======== + +Every module under ``src/polymath/extensions/`` holds plain functions that the binding +module attaches to :class:`~polymath.Qube`, as described in +:doc:`dev_guide_architecture`. Together they are the implementation of the class. This +chapter describes each module's responsibility, the methods it supplies, and the +invariants it maintains, so that a change lands in the right place. The public methods +are documented under :class:`~polymath.Qube` in :doc:`/module`; the modules themselves, +with their private functions, are documented in :doc:`dev_guide_internal_api`. + +Two of the modules are exceptions to the pattern. The iterator module defines two +classes, which are documented as :mod:`polymath.extensions.iterator`, and the pickler +module defines the compression functions as module-level functions, documented as +:mod:`polymath.extensions.pickler`, in addition to the methods it binds. + +Module by Module +================ + +.. list-table:: + :header-rows: 1 + :widths: 18 82 + + * - Module + - Responsibility + * - ``attr_ops`` + - :meth:`~polymath.Qube.add_attr`, the only public function. It records the added + name in the object's frozenset of added attributes, replacing the set rather than + modifying it, because the class-level default is shared by every object. + * - ``broadcaster`` + - :meth:`~polymath.Qube.broadcast_to`, :meth:`~polymath.Qube.broadcast`, + :meth:`~polymath.Qube.broadcasted_shape`, and + :meth:`~polymath.Qube.broadcast_into_shape`. Broadcasting applies to the leading + axes only, and a broadcast object shares memory with its source, so it is returned + read-only. + * - ``casting`` + - Tests for a lone value (:meth:`~polymath.Qube.is_one_true`, + :meth:`~polymath.Qube.is_one_false`, :meth:`~polymath.Qube.as_one_bool`) and + conversions between classes (:meth:`~polymath.Qube.cast`, + :meth:`~polymath.Qube.as_this_type`, :meth:`~polymath.Qube.as_all_constant`, + :meth:`~polymath.Qube.as_size_zero`). Conversion consults the class constants of + the target and the derivative class of the source. + * - ``deriv_ops`` + - Insertion, deletion, and renaming of derivatives, and the copies with and without + them. Insertion broadcasts the derivative to the object's shape, converts it to the + derivative class, makes it read-only if the object is, and sets the ``d_d`` + attribute. Every other module that touches derivatives goes through these + functions. + * - ``dtypes`` + - Interpretation of an arbitrary constructor argument as a data type, a value, and + a mask; the checks :meth:`~polymath.Qube.is_float`, :meth:`~polymath.Qube.is_int`, + and :meth:`~polymath.Qube.is_bool`; and the conversions + :meth:`~polymath.Qube.as_float`, :meth:`~polymath.Qube.as_int`, and + :meth:`~polymath.Qube.as_bool`. Its three helpers that take the class as their + first argument are wrapped in ``classmethod`` at the binding site. + * - ``errors`` + - The functions that raise the shared exceptions, so that every message names the + operation, the classes, and the shapes the same way, along with the preconditions + many operations begin with, such as requiring an object to have no denominator or + to be a :class:`~polymath.Scalar`. + * - ``indexer`` + - :meth:`~polymath.Qube.__getitem__` and :meth:`~polymath.Qube.__setitem__`. The + index is first normalized: a :class:`~polymath.Boolean` or an integer + :class:`~polymath.Qube` becomes a NumPy index plus a mask, a + :class:`~polymath.Pair` or :class:`~polymath.Vector` becomes an index into + consecutive axes, and the rule that places the broadcasted shape of several + array indices at the position of the first one is applied here. Assignment + requires a writable object and leaves elements selected by a masked index + unchanged. + * - ``item_ops`` + - Restructuring of the item axes: extraction, slicing, reshaping, flattening, and + transposition of the numerator and denominator separately, joining and splitting + of the two, :meth:`~polymath.Qube.chain`, and the ``@`` operator. These are how + a derivative's denominator is manipulated and how an object is reinterpreted as + another class. + * - ``iterator`` + - :meth:`~polymath.Qube.__iter__`, which walks the first axis, and + :meth:`~polymath.Qube.ndenumerate`, which walks every item with its index. + * - ``masking`` + - Conversion of an argument into a mask of suitable shape, the combining functions + :meth:`~polymath.Qube.or_` and :meth:`~polymath.Qube.and_`, the counts, and the + copies with a replaced, removed, expanded, or collapsed mask. A mask is a Python + bool or a boolean array of the object's shape, never anything else, and this + module is where that is enforced. + * - ``mask_ops`` + - The ``mask_where`` family, :meth:`~polymath.Qube.clip`, and the range tests + :meth:`~polymath.Qube.is_inside`, :meth:`~polymath.Qube.is_outside`, + :meth:`~polymath.Qube.is_above`, and :meth:`~polymath.Qube.is_below`. Each masks + elements by value and optionally replaces them. + * - ``math_ops`` + - The unary, binary, in-place, and reflected arithmetic operators, the comparison + and logical operators, :meth:`~polymath.Qube.__bool__`, + :meth:`~polymath.Qube.__float__`, :meth:`~polymath.Qube.__int__`, and the + reductions :meth:`~polymath.Qube.sum`, :meth:`~polymath.Qube.mean`, + :meth:`~polymath.Qube.any`, and :meth:`~polymath.Qube.all`. It is the largest + module and the one described under operator dispatch in + :doc:`dev_guide_architecture`. The binding module sets the hash to None + immediately after binding the equality operator from here. + * - ``pickler`` + - :meth:`~polymath.Qube.__getstate__` and :meth:`~polymath.Qube.__setstate__`, the + encoders and decoders for float, integer, and boolean arrays, and the precision + settings. Only the unmasked elements are stored; masked elements are restored + from the default value. + * - ``readonly_ops`` + - :meth:`~polymath.Qube.as_readonly`, :meth:`~polymath.Qube.require_writeable`, + :meth:`~polymath.Qube.match_readonly`, :meth:`~polymath.Qube.copy`, and + :meth:`~polymath.Qube.__copy__`. Read-only status is implemented by clearing the + writable flag of the values array and the mask array, so a determined caller can + defeat it; the API only makes modification difficult. + * - ``shaper`` + - :meth:`~polymath.Qube.reshape`, :meth:`~polymath.Qube.flatten`, + :meth:`~polymath.Qube.swap_axes`, :meth:`~polymath.Qube.roll_axis`, + :meth:`~polymath.Qube.move_axis`, and :meth:`~polymath.Qube.stack`. Each applies + the same change to the derivatives, and each returns a view where NumPy can + provide one. + * - ``shrinker`` + - :meth:`~polymath.Qube.shrink` and :meth:`~polymath.Qube.unshrink`, including the + caching of each other's results. + * - ``tvl`` + - The three-valued logic operations, in which a masked value means "maybe". + * - ``unit_ops`` + - :meth:`~polymath.Qube.set_unit`, :meth:`~polymath.Qube.without_unit`, + :meth:`~polymath.Qube.into_unit`, :meth:`~polymath.Qube.confirm_unit`, + :meth:`~polymath.Qube.is_unitless`, and the private checks that an operation's + operands have compatible units, that an argument is an angle, or that an object + is unitless. + * - ``vector_ops`` + - :meth:`~polymath.Qube.dot`, :meth:`~polymath.Qube.norm`, + :meth:`~polymath.Qube.norm_sq`, :meth:`~polymath.Qube.cross`, + :meth:`~polymath.Qube.outer`, :meth:`~polymath.Qube.as_diagonal`, and + :meth:`~polymath.Qube.rms`, each operating on a chosen pair of item axes. They + are defined here rather than on :class:`~polymath.Vector` because they apply to + any object whose item axes have suitable lengths, and the + :class:`~polymath.Vector` methods of the same names are thin wrappers that fix + the axes and the result class. + +Invariants Every Extension Must Respect +======================================= + +* **Values are in standard units.** Never scale values by a unit inside an operation. + A unit is checked for compatibility or combined, and the values are left alone. +* **Masks combine with logical or.** The mask of a result is the union of the masks of + the operands, plus whatever the operation itself could not compute. Use the combining + functions in the masking module rather than NumPy directly, because a mask may be a + single bool. +* **Derivatives are propagated unless ``recursive`` is False.** A method that produces a + new value and takes a ``recursive`` keyword must propagate every derivative when it is + True and produce an object with no derivatives when it is False. Insert derivatives + through the derivative operations module so that broadcasting, class conversion, and + read-only status are handled once. +* **Shared memory is read-only.** An operation whose result shares memory with its + input, such as a reshape, a broadcast, or a shrink, returns a read-only object. An + operation that computes fresh values may return a writable one. +* **Never modify an operand.** Operations return new objects; only the in-place operators + and the methods documented as in-place modify their receiver, and those call + :meth:`~polymath.Qube.require_writeable` first. +* **Clear or prune the cache.** Anything that changes an object clears its cache, and a + copy that retains the cache drops the entries that describe the original. +* **Shrinking is transparent.** The result of an operation must not depend on whether + its operands were shrunk, and the switches described in :doc:`dev_guide_architecture` + must leave every test passing. +* **Reach subclasses through the registry.** Use ``Qube._SCALAR_CLASS`` and its + siblings rather than importing a subclass module. + +API Reference +============= + +The public methods are listed under :class:`~polymath.Qube` in :doc:`/module`. The +extension modules themselves, including their private functions, are in +:doc:`dev_guide_internal_api`. diff --git a/docs/dev_guide/dev_guide_internal_api.rst b/docs/dev_guide/dev_guide_internal_api.rst new file mode 100644 index 0000000..7c67a48 --- /dev/null +++ b/docs/dev_guide/dev_guide_internal_api.rst @@ -0,0 +1,249 @@ +====================== +Internal API Reference +====================== + +This page is a second copy of the API reference, generated from the same docstrings as +:doc:`/module` but including the private methods, the private module-level helpers, the +special methods, and the class constants. It also documents each extension module as a +module, so that a function can be found where it is defined as well as under the name it +is bound to on :class:`~polymath.Qube`. + +Nothing on this page is part of the public API. A name beginning with an underscore may +change without notice. The class entries here duplicate the public ones and are not +indexed, so a cross-reference to a class or method always resolves to :doc:`/module`. + +The Base Class +============== + +.. automodule:: polymath.qube + :no-members: + +.. autoclass:: polymath.Qube + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +The Extension Modules +===================== + +The methods that the modules below define are bound onto :class:`~polymath.Qube` and are +listed under the base class above. Here they appear as the module-level functions they +are, together with each module's private helpers. + +.. automodule:: polymath.extensions + :members: + +.. automodule:: polymath.extensions.attr_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.broadcaster + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.casting + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.deriv_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.dtypes + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.errors + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.indexer + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + +.. automodule:: polymath.extensions.item_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + +The iterator module is documented in full as :mod:`polymath.extensions.iterator`. + +.. automodule:: polymath.extensions.mask_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.masking + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.math_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + +The public functions of the pickler module are documented as +:mod:`polymath.extensions.pickler`; its private encoders and decoders are listed under +the base class above. + +.. automodule:: polymath.extensions.readonly_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + +.. automodule:: polymath.extensions.shaper + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.shrinker + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.tvl + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.unit_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + +.. automodule:: polymath.extensions.vector_ops + :member-order: bysource + :members: + :undoc-members: + :private-members: + +The Subclasses +============== + +Each class is listed with the members it defines itself; inherited members appear under +the parent class. + +.. autoclass:: polymath.Scalar + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Boolean + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Vector + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Pair + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Vector3 + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Quaternion + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Polynomial + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Matrix + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +.. autoclass:: polymath.Matrix3 + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ + +The Unit Class +============== + +.. autoclass:: polymath.Unit + :no-index: + :member-order: bysource + :members: + :undoc-members: + :private-members: + :special-members: + :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ diff --git a/docs/dev_guide/dev_guide_introduction.rst b/docs/dev_guide/dev_guide_introduction.rst new file mode 100644 index 0000000..de44dff --- /dev/null +++ b/docs/dev_guide/dev_guide_introduction.rst @@ -0,0 +1,57 @@ +============ +Introduction +============ + +Who This Guide Is For +===================== + +This guide is for anyone who changes PolyMath: fixing a bug, adding a method, defining a +subclass, or cutting a release. It assumes a competent Python developer who is fluent with +NumPy and pytest but new to this code base. The :doc:`/user_guide/user_guide` explains how +to use the package and never asks its reader to open the source. This guide is about the +source, and it concentrates on the contracts that hold the package together rather than +restating what the code says. + +Package Overview +================ + +PolyMath, distributed as ``rms-polymath`` and imported as :mod:`polymath`, wraps NumPy +arrays in objects that keep the shape of an array separate from the shape of its items, +and that carry a mask, an optional :class:`~polymath.Unit`, and optional derivatives +through every operation. One base class, :class:`~polymath.Qube`, implements all of that +machinery. The nine public subclasses, :class:`~polymath.Scalar`, +:class:`~polymath.Boolean`, :class:`~polymath.Vector`, :class:`~polymath.Pair`, +:class:`~polymath.Vector3`, :class:`~polymath.Quaternion`, :class:`~polymath.Polynomial`, +:class:`~polymath.Matrix`, and :class:`~polymath.Matrix3`, add constraints on the item +shape and the operations that make sense for it. + +Two design decisions shape everything a developer touches, and both are explained in +:doc:`dev_guide_architecture`: + +* The file for :class:`~polymath.Qube` holds only what defines an object. Nearly every + method is written as a plain function in a module under ``src/polymath/extensions/`` + and bound onto the class when the package is imported. +* The source carries no type annotations. Public type information lives in two stub + files, ``__init__.pyi`` and ``typedefs.pyi``, matching the only two supported import + paths, and a check called stubtest keeps them honest. + +Runtime requirements are Python 3.11 or later, NumPy 2.0 or later, and ``rms-fpzip``, +which compresses floating-point arrays when objects are pickled. The package runs on +Linux, macOS, and Windows, and the test matrix covers all three. + +Development requirements are declared as extras in ``pyproject.toml``. The ``dev`` extra +brings the linters, the test tools, the packaging check, and the type checker whose +stubtest subcommand validates the stubs; it also pulls in the ``docs`` extra, which +brings Sphinx 9 or later and its extensions. + +Where to Look +============= + +* :doc:`/module` is the public API reference, generated from the docstrings. +* :doc:`dev_guide_internal_api` is a second copy of the API reference that includes + the private methods and the extension modules, generated from the same docstrings. +* :doc:`/contributing` is the contribution guide, covering issues, enhancement requests, + and the legal terms of a code contribution. +* ``CLAUDE.md`` and the files under ``.claude/rules/`` in the repository root are the + detailed working rules, written for AI-assisted development but binding on everyone. + :doc:`dev_guide_conventions` summarizes them. diff --git a/docs/dev_guide/dev_guide_layout.rst b/docs/dev_guide/dev_guide_layout.rst new file mode 100644 index 0000000..840f9d9 --- /dev/null +++ b/docs/dev_guide/dev_guide_layout.rst @@ -0,0 +1,75 @@ +================= +Repository Layout +================= + +The importable package is everything under ``src/polymath/``. Everything else supports it: +tests, documentation, scripts, and configuration. + +:: + + rms-polymath/ + ├── pyproject.toml # Packaging metadata and the configuration of every tool + ├── requirements.txt # Contains only "-e ."; dependencies live in pyproject.toml + ├── .flake8 # Per-file exemptions for the continuation-line checks + ├── .stubtest-allowlist # The modules that deliberately have no stub + ├── .readthedocs.yaml # ReadTheDocs build: installs the docs extra, runs Sphinx + ├── codecov.yml # Coverage upload configuration + ├── README.md # Front page, included into the Sphinx documentation + ├── CONTRIBUTING.md # Contribution guide, included into the Sphinx documentation + ├── CODE_OF_CONDUCT.md # Code of conduct, included into the Sphinx documentation + ├── LICENSE # Apache License 2.0 + ├── CLAUDE.md # Working rules for AI-assisted development + ├── .claude/ # Detailed rules and skills for AI-assisted development + ├── .github/ + │ ├── workflows/ # GitHub Actions: tests, dependency audit, publishing + │ ├── ISSUE_TEMPLATE/ # Issue templates + │ └── pull_request_template.md + ├── .vscode/settings.json # Editor settings: 4-space indent, rulers at 80 and 90 + ├── scripts/ + │ ├── setup-venv.sh # Creates ./venv and installs the dev and docs extras + │ ├── run-all-checks.sh # Runs every quality gate; the source of truth for CI + │ └── read-docs.sh # Builds the documentation and opens it in a browser + ├── docs/ # Sphinx documentation source + │ ├── conf.py # The single Sphinx configuration + │ ├── index.rst # Root page; includes README.md and the top-level toctree + │ ├── module.rst # Public API reference, generated by autodoc + │ ├── contributing.rst # Wraps CONTRIBUTING.md + │ ├── user_guide/ # The user guide, one chapter per file + │ └── dev_guide/ # This guide, one chapter per file + ├── src/polymath/ # The importable package + │ ├── __init__.py # Public namespace; imports extensions before any subclass + │ ├── __init__.pyi # Stub declaring every public class; one of two stubs + │ ├── py.typed # PEP 561 marker: the stubs are authoritative + │ ├── _version.py # Written by setuptools_scm at build time; never edit + │ ├── qube.py # The Qube base class: constants, constructor, properties + │ ├── scalar.py # Scalar + │ ├── boolean.py # Boolean, a subclass of Scalar + │ ├── vector.py # Vector + │ ├── pair.py # Pair, a subclass of Vector + │ ├── vector3.py # Vector3, a subclass of Vector + │ ├── quaternion.py # Quaternion, a subclass of Vector + │ ├── polynomial.py # Polynomial, a subclass of Vector + │ ├── matrix.py # Matrix + │ ├── matrix3.py # Matrix3, a subclass of Matrix + │ ├── unit.py # Unit, which is not a Qube + │ ├── typedefs.py # Public type aliases + │ ├── typedefs.pyi # Stub mirroring the aliases; the other of the two stubs + │ └── extensions/ # Functions bound onto Qube when the package is imported + │ ├── __init__.py # The binding, one assignment per method + │ ├── math_ops.py # Operators, comparisons, and reductions + │ ├── masking.py # Mask construction and mask properties + │ └── ... # One module per concern; see the extensions chapter + ├── tests/ # The pytest suite, flat, named test__.py + └── icons/ # Images used by README.md + +The package modules follow one pattern. Each begins with a banner comment naming the +file, a module docstring, the imports in three aligned groups, and an ``__all__`` list. +Each subclass module ends with the read-only class constants, such as +:attr:`~polymath.Scalar.ZERO`, and with an assignment that registers the class on +:class:`~polymath.Qube` for the extension modules to use. :doc:`dev_guide_subclasses` +describes both. + +The test suite is flat rather than mirroring the source tree. Tests for a method bound +from an extension module are named for the class they exercise and the topic, so the +tests for the shaper module are in ``tests/test_qube_reshaping.py`` and the tests for +the mask operations are in ``tests/test_qube_ext_mask_ops.py``. diff --git a/docs/dev_guide/dev_guide_subclasses.rst b/docs/dev_guide/dev_guide_subclasses.rst new file mode 100644 index 0000000..ce6a9f4 --- /dev/null +++ b/docs/dev_guide/dev_guide_subclasses.rst @@ -0,0 +1,239 @@ +============== +The Subclasses +============== + +Overview +======== + +Each subclass lives in its own module under ``src/polymath/``, and each follows the same +pattern: the class constants, a static conversion method named after the class, the +methods that give the class its meaning, and at the bottom of the module the read-only +class constants and the registration of the class on :class:`~polymath.Qube`. This +chapter describes what each class fixes and what it overrides, so that a change lands at +the right level of the hierarchy. The base class itself is covered in +:doc:`dev_guide_architecture`. + +The Module Pattern +================== + +**Class constants.** Every subclass module sets all seven of the constraint constants +described in :doc:`dev_guide_architecture`, one per line with a trailing comment, even +where a value merely repeats the parent's. The table gives the values in use. A dash means +the constant is inherited. + +.. list-table:: + :header-rows: 1 + :widths: 16 9 10 9 9 9 9 9 20 + + * - Class + - Rank + - Numerator + - Floats + - Ints + - Bools + - Units + - Derivs + - Default value + * - :class:`~polymath.Scalar` + - 0 + - ``()`` + - yes + - yes + - no + - yes + - yes + - 1 + * - :class:`~polymath.Boolean` + - 0 + - ``()`` + - no + - no + - yes + - no + - no + - False + * - :class:`~polymath.Vector` + - 1 + - free + - yes + - yes + - no + - yes + - yes + - ones + * - :class:`~polymath.Pair` + - 1 + - ``(2,)`` + - yes + - yes + - no + - yes + - yes + - ``[1, 1]`` + * - :class:`~polymath.Vector3` + - 1 + - ``(3,)`` + - yes + - no + - no + - yes + - yes + - ``[1, 1, 1]`` + * - :class:`~polymath.Quaternion` + - 1 + - ``(4,)`` + - yes + - no + - no + - no + - yes + - ``[1, 0, 0, 0]`` + * - :class:`~polymath.Polynomial` + - 1 + - free + - yes + - no + - -- + - -- + - -- + - ones + * - :class:`~polymath.Matrix` + - 2 + - free + - yes + - no + - no + - yes + - yes + - ones + * - :class:`~polymath.Matrix3` + - 2 + - ``(3, 3)`` + - yes + - no + - no + - no + - yes + - identity + +**The converter.** Each class has a static method, such as +:meth:`~polymath.Pair.as_pair`, that converts an arbitrary argument to the class. It +returns an argument of the right class unchanged (or without its derivatives when +``recursive`` is False), reinterprets the item axes of any other :class:`~polymath.Qube` +by flattening a 1xN or Nx1 numerator or by splitting surplus numerator axes into the +denominator, and passes anything else to the constructor. The converters with a fixed +item shape treat a single number as that number repeated. These methods are the +preferred way for an operation to accept either a PolyMath object or a plain value, and +they are cheap when no conversion is needed. + +**The constants.** Each module ends by building the read-only constants of the class, +such as :attr:`~polymath.Scalar.ZERO`, :attr:`~polymath.Vector3.XAXIS`, and +:attr:`~polymath.Matrix3.IDENTITY`. They are built after the class body because +constructing them calls methods that the extension binding supplies, and they are made +read-only because they are shared by every caller. Every class has a constant holding a +single masked value. + +**The registration.** The final statement of each module assigns the class to an +attribute of :class:`~polymath.Qube`, such as ``Qube._PAIR_CLASS``, so that the extension +modules can reach it without importing it. + +Scalar and Boolean +================== + +:class:`~polymath.Scalar` holds a single number per item and is the only class that +defines the ordering comparisons, which return a :class:`~polymath.Boolean`. It overrides +:meth:`~polymath.Scalar.__pow__` with lookup tables for the common integer and +half-integer exponents, so that a square or a square root does not go through the general +power function, and it overrides :meth:`~polymath.Scalar.reciprocal`, +:meth:`~polymath.Scalar.identity`, and :meth:`~polymath.Scalar.abs`. Its own methods are +the transcendental functions, the reductions, the index conversions, and the quadratic +solver. The functions that can fail for some inputs, such as +:meth:`~polymath.Scalar.sqrt` and :meth:`~polymath.Scalar.arcsin`, take a ``check`` +keyword that decides whether to mask the offending elements or to let NumPy raise. + +:class:`~polymath.Boolean` is a :class:`~polymath.Scalar` restricted to truth values, with +units and derivatives disallowed. It overrides every arithmetic operator to convert itself +to an integer :class:`~polymath.Scalar` first, so that the sum of two truth values is a +count rather than a truth value, and it overrides :meth:`~polymath.Boolean.sum` to count +the True elements. :meth:`~polymath.Boolean.as_index` returns a NumPy boolean array for +indexing. The extension modules reach the class as ``Qube._BOOLEAN_CLASS``, which is how +the comparison operators build their results. + +Vector and Its Subclasses +========================= + +:class:`~polymath.Vector` fixes the numerator rank at one and leaves the length free. Its +constructor accepts a single Python number, and its methods are the vector algebra: +:meth:`~polymath.Vector.dot`, :meth:`~polymath.Vector.cross`, +:meth:`~polymath.Vector.norm`, :meth:`~polymath.Vector.unit`, and the rest. Most of these +are thin wrappers around the general functions in the vector operations extension module, +which operate on any object with suitable item axes; the wrappers fix the axes and the +result class. The conversions to and from :class:`~polymath.Scalar` components and to +:class:`~polymath.Matrix` rows, columns, and diagonals are here as well. + +:class:`~polymath.Pair` fixes the length at two. It adds the two-dimensional operations +and keeps integers allowed, because a :class:`~polymath.Pair` of integers is used as an +index into two axes. :class:`~polymath.Vector3` fixes the length at three, allows floats +only, and adds the conversions to and from spherical and cylindrical coordinates, +rotation about an axis, and the angular offsets. Both add a +:meth:`~polymath.Vector3.from_scalars` that assembles the vector from named components. + +:class:`~polymath.Quaternion` fixes the length at four and disallows units. It overrides +:meth:`~polymath.Quaternion.__mul__` and :meth:`~polymath.Quaternion.__truediv__` as the +quaternion product and its inverse, along with :meth:`~polymath.Quaternion.reciprocal` +and :meth:`~polymath.Quaternion.identity`, and it adds the conversions to and from a +:class:`~polymath.Matrix3`, an angle and axis, a scalar and vector part, and Euler +angles. The Euler conventions are shared with :class:`~polymath.Matrix3`. + +:class:`~polymath.Polynomial` leaves the length free but reinterprets the components as +coefficients in decreasing order of power. It is the one subclass that overrides +:meth:`~polymath.Polynomial.__init__`: a lone :class:`~polymath.Vector` argument is +converted by copying its attributes directly, and every derivative is converted to a +:class:`~polymath.Polynomial`, so that the derivatives of a polynomial are always +polynomials. It overrides the arithmetic operators as polynomial arithmetic and the +equality operators to compare polynomials of different orders, and it adds evaluation, +differentiation, root finding, and the :attr:`~polymath.Polynomial.order` property. It +is not registered on :class:`~polymath.Qube`, because no extension function needs it. + +Matrix and Matrix3 +================== + +:class:`~polymath.Matrix` fixes the numerator rank at two, allows floats only, and adds +the matrix algebra: :meth:`~polymath.Matrix.transpose`, :meth:`~polymath.Matrix.inverse`, +:meth:`~polymath.Matrix.solve`, :meth:`~polymath.Matrix.unitary`, and +:meth:`~polymath.Matrix.is_diagonal`, along with the row and column extractions. It +overrides :meth:`~polymath.Matrix.__abs__`, :meth:`~polymath.Matrix.__floordiv__`, and +:meth:`~polymath.Matrix.__mod__` to raise, because those operations have no meaning for a +matrix, and :meth:`~polymath.Matrix.identity` and :meth:`~polymath.Matrix.reciprocal` +to mean the identity matrix and the inverse. + +:class:`~polymath.Matrix3` fixes the shape at 3x3, disallows units, and represents +rotations. It names :class:`~polymath.Matrix` as its derivative class, because the +derivative of a rotation matrix is a general matrix. It overrides the additive operators +and negation to raise, since the sum of two rotations is not a rotation, and overrides +multiplication so that a product with a :class:`~polymath.Vector3` rotates the vector +and a product with another :class:`~polymath.Matrix3` composes the rotations. It adds the +constructors for rotations about an axis, from a pole, from two vectors, and from Euler +angles, and their inverses. It also overrides :meth:`~polymath.Matrix3.__getstate__` and +:meth:`~polymath.Matrix3.__setstate__` to pickle a rotation matrix as a +:class:`~polymath.Quaternion` when it can, which stores four numbers instead of nine. + +Unit +==== + +:class:`~polymath.Unit` is not a :class:`~polymath.Qube`. It holds three integer +exponents, a triple of integers defining an exact conversion factor, and an optional +name, and it implements the arithmetic that combines units and the conversions of values +into and out of a unit. Names are parsed and generated by +:meth:`~polymath.Unit.name_to_dict` and :meth:`~polymath.Unit.name_to_str`, and a +registry keyed by name serves :meth:`~polymath.Unit.as_unit`, which accepts only the +standard names; a compound unit such as kilometers per second is built with the +operators. The class constants for the common units are built at the bottom of the +module. Equality compares the exponents and the factor, not the name, so two units that +convert the same way are equal. + +API Reference +============= + +The public methods of every class are in :doc:`/module`. The modules, including their +private helpers and class constants, are in :doc:`dev_guide_internal_api`. diff --git a/docs/dev_guide/dev_guide_typing.rst b/docs/dev_guide/dev_guide_typing.rst new file mode 100644 index 0000000..9a9063a --- /dev/null +++ b/docs/dev_guide/dev_guide_typing.rst @@ -0,0 +1,110 @@ +=========================== +Type Stubs and Type Aliases +=========================== + +Overview +======== + +The rule for the source tree is that no module under ``src/`` carries type annotations, +with one exception described below. Parameter and return types belong in the docstrings, +where Napoleon renders them into the API reference. Public type information for +downstream type checkers is published separately, through stub files, and a check keeps +the two in agreement. + +The Stub Files +============== + +The only supported imports are ``from polymath import ...`` and +``from polymath.typedefs import ...``. A user never imports from a submodule such as +``polymath.scalar``, so the public type information lives in exactly two stubs: +``src/polymath/__init__.pyi``, which declares every public class in full, and +``src/polymath/typedefs.pyi``, which mirrors the aliases. The package ships a +``py.typed`` marker so that installed copies are recognized as typed. No other module +has a stub, and none may be added, because a per-module stub would make an import from +that module look supported. + +A stub replaces its module entirely for a type checker: whatever the stub omits becomes +invisible to downstream code. The two stubs must therefore cover the whole public +surface, and adding, renaming, or re-signing any public member means updating +``__init__.pyi`` in the same change. Most of the methods of :class:`~polymath.Qube` are +bound from the extension modules at import time, and every one of them must appear under +the class in ``__init__.pyi`` as though it were defined in the class body. The stub +follows these conventions: + +* The classes appear in dependency order: :class:`~polymath.Unit`, + :class:`~polymath.Qube`, and then each subclass after its parent. Within a class the + constants come first, then the methods in alphabetical order with the dunder methods + first. +* Signature shapes are exact: every parameter, its keyword-only status, and whether it + has a default, written as ``...``. +* Types come from the docstrings where those state one unambiguously, and are ``Any`` + where they do not. An ``Any`` is a deliberate statement that the docstring does not + commit to a type, not an omission to fill in by guessing. +* Constructor arguments use the aliases from :mod:`polymath.typedefs`, which + ``__init__.pyi`` imports from ``typedefs.pyi``. The two stubs import each other, which + a type checker accepts. +* A method whose signature deliberately differs from its parent's carries a + ``# type: ignore[override]`` comment. + +The stubtest subcommand of mypy compares the stubs against the runtime API and fails on +any discrepancy. It runs in the check script and in CI: + +.. code-block:: sh + + python -m mypy.stubtest polymath --mypy-config-file pyproject.toml --allowlist .stubtest-allowlist + +Two pieces of configuration make this work, and both name the stub-less modules +explicitly, so a new module must be added to each. The ``exclude`` setting and the +per-module overrides under ``[tool.mypy]`` in ``pyproject.toml`` keep mypy from building +or following an import into the unannotated modules, which it would otherwise compare +against themselves. The allowlist in ``.stubtest-allowlist`` accepts the one finding +that remains for each of them, that no stub exists; it accepts nothing else, so a public +name missing from the two stubs still fails the check. Two further consequences for +authors: a module-level ``@classmethod`` in an extension module is not a function and +stubtest rejects it, so the binding module wraps such functions instead; and a method +stubtest cannot see at runtime, such as one bound conditionally, cannot appear in the +stub. + +The Property Exception +====================== + +A property may carry an inline return annotation in the source. A property has no +parameters, and Sphinx renders the annotation as the property's type beside its name, so +that :attr:`~polymath.Qube.shape` reads as a property of type ``tuple[int, ...]``. A +property documented only through a ``Returns:`` block renders its type on a separate +trailing line instead, so the two styles do not mix: annotate the property and keep its +docstring to a one-line summary. Where the annotation names something from +:mod:`polymath.typedefs`, quote it and import it under ``if TYPE_CHECKING:``, because +that module imports :class:`~polymath.Qube` and a runtime import would be circular. + +The Type Aliases +================ + +:mod:`polymath.typedefs` is the one module under ``src/`` that is written with +annotations, because its purpose is to define them. Each public alias names what the +corresponding constructor accepts: a :class:`~polymath.Qube`, a NumPy array with the +required trailing axes, a nested sequence, and for the rank-0 classes a single number. +:data:`~polymath.typedefs.PairLike` names a single number as well, because +:meth:`~polymath.Pair.as_pair` repeats a lone value across both components of the pair. +The private aliases that build them describe NumPy arrays by their shape type. The +aliases are ordinary runtime objects, used both by the stubs and by downstream code. +``typedefs.pyi`` repeats the same definitions for the type checker, taking +:class:`~polymath.Qube` from the package rather than from its module, so that the two +stay in step by construction: an alias added to one must be added to the other, and +stubtest reports one that is not. + +Sphinx documents each alias from the docstring that follows it. Sphinx 9 is the first +release whose Python domain resolves a class reference, which Napoleon generates for +every docstring type, to the data target that autodoc creates for a type alias, so the +``docs`` extra requires that version. + +Checking the Tests +================== + +The tests are fully annotated, and mypy in strict mode runs against ``tests/`` when the +``--mypy`` check is enabled. That run reads the stubs, so it is also the most realistic +test that the stubs describe an API a downstream project can use. Run it by hand with: + +.. code-block:: sh + + MYPYPATH=src mypy tests diff --git a/docs/index.rst b/docs/index.rst index 801390e..9fd1e4a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,14 @@ Welcome to the Documentation for rms-polymath! .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Guides: + + user_guide/user_guide + dev_guide/dev_guide + +.. toctree:: + :maxdepth: 2 + :caption: API Reference: module diff --git a/docs/module.rst b/docs/module.rst index 5f49fb2..d205f3c 100644 --- a/docs/module.rst +++ b/docs/module.rst @@ -9,6 +9,12 @@ :show-inheritance: :exclude-members: __dict__, __hash__, __module__, __weakref__, __annotations__, __abstractmethods__ +``polymath.typedefs`` Module +============================ + +.. automodule:: polymath.typedefs + :members: + ``polymath.extensions.iterator`` Module ======================================= diff --git a/docs/user_guide/user_guide.rst b/docs/user_guide/user_guide.rst new file mode 100644 index 0000000..61eb19f --- /dev/null +++ b/docs/user_guide/user_guide.rst @@ -0,0 +1,20 @@ +========== +User Guide +========== + +This guide is the manual for using PolyMath as a library: constructing objects, computing +with them, and working with the masks, derivatives, and units they carry. It assumes +fluency with Python and NumPy but no knowledge of the package internals. + +.. toctree:: + :maxdepth: 2 + + user_guide_introduction + user_guide_objects + user_guide_math + user_guide_masks + user_guide_derivatives + user_guide_units + user_guide_indexing + user_guide_pickling + user_guide_typing diff --git a/docs/user_guide/user_guide_derivatives.rst b/docs/user_guide/user_guide_derivatives.rst new file mode 100644 index 0000000..69f05e1 --- /dev/null +++ b/docs/user_guide/user_guide_derivatives.rst @@ -0,0 +1,142 @@ +=========== +Derivatives +=========== + +A PolyMath object can carry derivatives, each of which is another PolyMath object of the +same shape. Every operator and math function propagates them, applying the chain rule as +it goes, so an algorithm written once in terms of positions yields velocities for free +when the positions carry a time derivative. + +Attaching a Derivative +====================== + +:meth:`~polymath.Qube.insert_deriv` attaches a derivative under a name. The +:attr:`~polymath.Qube.derivs` property is the dictionary of all of them, and each is also +available as an attribute named ``d_d`` followed by the name, so the derivative with +respect to ``t`` is ``d_dt``. Each derivative appears as a suffix in the printed form. + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Pair, Scalar, Vector3 + >>> t = Scalar([0., 1., 2.]) + >>> x = t ** 2 + >>> x.insert_deriv('t', 2 * t) + Scalar(0. 1. 4.; d_dt) + >>> x.d_dt + Scalar(0. 2. 4.) + >>> list(x.derivs) + ['t'] + +Propagation +=========== + +Operations apply the chain rule. The derivative of the sine of ``x`` with respect to +``t`` is the cosine of ``x`` times the derivative of ``x``, and the product rule applies +to a product: + +.. code-block:: python + + >>> x.sin().d_dt + Scalar( 0. 1.08060461 -2.61457448) + >>> (x * x).d_dt + Scalar( 0. 4. 32.) + +The same holds for vectors. If a position carries a velocity, every quantity derived from +it carries its own rate of change: + +.. code-block:: python + + >>> pos = Vector3([3., 4., 0.]) + >>> pos.insert_deriv('t', Vector3([1., 0., 0.])) + Vector3(3. 4. 0.; d_dt) + >>> pos.norm().d_dt + Scalar(0.6) + >>> pos.unit().d_dt + Vector3( 0.128 -0.096 0. ) + +Derivatives cost time. Most methods take a ``recursive`` keyword, True by default; pass +``recursive=False`` to compute the value alone. :attr:`~polymath.Qube.wod`, short for +"without derivatives", returns a shallow copy with no derivatives, which is the cheapest +way to drop them for the rest of a calculation. + +.. code-block:: python + + >>> x.sin(recursive=False) + Scalar( 0. 0.84147098 -0.7568025 ) + >>> x.wod + Scalar(0. 1. 4.) + +Partial Derivatives and Denominators +==================================== + +The derivative of a vector with respect to a scalar is a vector with the same item shape. +The derivative of a vector with respect to another vector has more components: the partial +derivatives of a :class:`~polymath.Vector3` with respect to a :class:`~polymath.Pair` form +a 3x2 array of numbers. PolyMath represents this by splitting the item axes into a +numerator, which is the item shape of the quantity being differentiated, and a +denominator, which is the item shape of the variable. The :attr:`~polymath.Qube.numer`, +:attr:`~polymath.Qube.denom`, :attr:`~polymath.Qube.nrank`, and +:attr:`~polymath.Qube.drank` properties describe the split. Construct such an object with +the ``drank`` argument, which states how many trailing axes belong to the denominator. + +.. code-block:: python + + >>> dpos_duv = Vector3([[1., 0.], [0., 1.], [0., 0.]], drank=1) + >>> dpos_duv + Vector3([[1. 0.] + [0. 1.] + [0. 0.]]; denom=(2,)) + >>> dpos_duv.numer, dpos_duv.denom, dpos_duv.item + ((3,), (2,), (3, 2)) + +A class constrains only the numerator, so this object is still a +:class:`~polymath.Vector3`, and the derivative of a :class:`~polymath.Vector3` with respect +to anything can be attached to one. Propagation keeps the denominator: + +.. code-block:: python + + >>> pos = Vector3([1., 2., 3.]) + >>> pos.insert_deriv('uv', dpos_duv) + Vector3(1. 2. 3.; d_duv) + >>> pos.norm().d_duv + Scalar([0.26726124 0.53452248]; denom=(2,)) + +:meth:`~polymath.Qube.chain` multiplies derivatives together, contracting the denominator +of one against the numerator of the next; the ``@`` operator does the same. Given the +derivative of a position with respect to a coordinate pair and the derivative of that pair +with respect to time, the chain gives the derivative of the position with respect to time: + +.. code-block:: python + + >>> duv_dt = Pair([1., 2.]) + >>> dpos_duv @ duv_dt + Vector3(1. 2. 0.) + +Managing Derivatives +==================== + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Method + - Effect + * - :meth:`~polymath.Qube.insert_deriv`, :meth:`~polymath.Qube.insert_derivs` + - Attach one derivative, or a dictionary of them, in place. + * - :meth:`~polymath.Qube.delete_deriv`, :meth:`~polymath.Qube.delete_derivs` + - Remove one derivative, or all of them, in place. + * - :meth:`~polymath.Qube.rename_deriv` + - Rename a derivative in place. + * - :meth:`~polymath.Qube.with_deriv`, :meth:`~polymath.Qube.without_deriv`, + :meth:`~polymath.Qube.without_derivs`, :attr:`~polymath.Qube.wod` + - Return a shallow copy with a derivative added or removed. + +The item axes of a derivative can be rearranged with +:meth:`~polymath.Qube.extract_numer`, :meth:`~polymath.Qube.extract_denom`, +:meth:`~polymath.Qube.extract_denoms`, :meth:`~polymath.Qube.slice_numer`, +:meth:`~polymath.Qube.transpose_numer`, :meth:`~polymath.Qube.reshape_numer`, +:meth:`~polymath.Qube.flatten_numer`, :meth:`~polymath.Qube.transpose_denom`, +:meth:`~polymath.Qube.reshape_denom`, :meth:`~polymath.Qube.flatten_denom`, +:meth:`~polymath.Qube.join_items`, :meth:`~polymath.Qube.split_items`, and +:meth:`~polymath.Qube.swap_items`. diff --git a/docs/user_guide/user_guide_indexing.rst b/docs/user_guide/user_guide_indexing.rst new file mode 100644 index 0000000..3153638 --- /dev/null +++ b/docs/user_guide/user_guide_indexing.rst @@ -0,0 +1,137 @@ +====================== +Indexing and Iteration +====================== + +Indexing a PolyMath object works much as indexing a NumPy array does, with the index +applying to the shape and leaving the items intact. Beyond what NumPy accepts, an index +may itself be a PolyMath object, and a masked index selects nothing at the masked +locations. + +Basic Indexing +============== + +Integers, slices, ellipses, and NumPy arrays index the leading axes. + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Boolean, Pair, Scalar, Vector3 + >>> a = Scalar(np.arange(12.).reshape(3, 4)) + >>> a[0] + Scalar(0. 1. 2. 3.) + >>> a[1, 2] + Scalar(6.0) + >>> a[:, 1] + Scalar(1. 5. 9.) + >>> a[..., -1] + Scalar( 3. 7. 11.) + +An index of ``True`` selects the whole object, and an index of ``False`` selects nothing, +leaving a first axis of length zero. + +.. code-block:: python + + >>> a[True].shape + (3, 4) + >>> a[False].shape + (0, 4) + +Indexing with PolyMath Objects +============================== + +A :class:`~polymath.Boolean` selects the elements where it is True, like a boolean NumPy +array. A :class:`~polymath.Scalar` of integers selects by position, like an integer NumPy +array. A :class:`~polymath.Pair` of integers indexes two consecutive axes at once, and a +:class:`~polymath.Vector` with more components indexes as many axes. In every case, the +elements selected by a masked location of the index come back masked. + +.. code-block:: python + + >>> a[Boolean([True, False, True])] + Scalar([ 0. 1. 2. 3.] + [ 8. 9. 10. 11.]) + >>> a[Boolean([True, False, True], mask=[False, False, True])] + Scalar([0.0 1.0 2.0 3.0] + [-- -- -- --]; mask) + >>> a[Scalar([0, 2])] + Scalar([ 0. 1. 2. 3.] + [ 8. 9. 10. 11.]) + >>> a[Pair([[0, 1], [2, 3]])] + Scalar( 1. 11.) + +As in NumPy, the shape of an array-valued index appears in the shape of the result. When +several array-valued indices are used together, their broadcasted shape appears at the +position of the first one, which differs slightly from the NumPy rule for indices +separated by a slice. With ``A`` of shape ``(6, 7, 8, 9)``, ``B`` of shape ``(3, 1)``, and +``C`` of shape ``(4,)``: + +.. code-block:: python + + >>> A = Scalar(np.zeros((6, 7, 8, 9))) + >>> B = Scalar(np.zeros((3, 1), dtype=int)) + >>> C = Scalar(np.zeros((4,), dtype=int)) + >>> A[B].shape + (3, 1, 7, 8, 9) + >>> A[:, B].shape + (6, 3, 1, 8, 9) + >>> A[B, C].shape + (3, 4, 8, 9) + >>> A[:, B, :, C].shape + (6, 3, 4, 8) + +Assigning by Index +================== + +Assignment through an index modifies the object in place, and it requires a writable +object. Where a :class:`~polymath.Boolean` or :class:`~polymath.Scalar` index is masked, +the corresponding elements are left unchanged. + +.. code-block:: python + + >>> b = Scalar(np.zeros(4)) + >>> b[1] = 5. + >>> b[Boolean([False, False, True, True])] = 7. + >>> b + Scalar(0. 5. 7. 7.) + >>> b[Scalar([0, 3], mask=[False, True])] = 9. + >>> b + Scalar(9. 5. 7. 7.) + +Indexing NumPy Arrays +===================== + +Several methods convert a PolyMath object into something that can index a NumPy array. +:meth:`~polymath.Scalar.as_index` and :meth:`~polymath.Vector.as_index` return integer +indices, :meth:`~polymath.Scalar.as_index_and_mask` and +:meth:`~polymath.Vector.as_index_and_mask` return the indices together with a mask, and +:meth:`~polymath.Boolean.as_index` returns a boolean array. The +:attr:`~polymath.Qube.antimask` property and the :meth:`~polymath.Qube.as_mask_where_zero` +family described in :doc:`user_guide_masks` return boolean arrays derived from the mask. + +.. code-block:: python + + >>> Scalar([1, 0]).as_index() + array([1, 0]) + >>> Boolean([True, False]).as_index() + array([ True, False]) + +Iteration +========= + +Iterating over an object walks its first axis, yielding one object per index, and +:func:`len` gives the length of that axis. :meth:`~polymath.Qube.ndenumerate` iterates +over every item of a multidimensional object, yielding each index along with the item. + +.. code-block:: python + + >>> v = Vector3(np.arange(6.).reshape(2, 3)) + >>> for item in v: + ... print(item) + Vector3(0. 1. 2.) + Vector3(3. 4. 5.) + >>> for index, item in Scalar([[1., 2.], [3., 4.]]).ndenumerate(): + ... print(index, item) + (0, 0) Scalar(1.0) + (0, 1) Scalar(2.0) + (1, 0) Scalar(3.0) + (1, 1) Scalar(4.0) diff --git a/docs/user_guide/user_guide_introduction.rst b/docs/user_guide/user_guide_introduction.rst new file mode 100644 index 0000000..948aafb --- /dev/null +++ b/docs/user_guide/user_guide_introduction.rst @@ -0,0 +1,191 @@ +============================= +Introduction and Installation +============================= + +Purpose +======= + +PolyMath is a wrapper around NumPy for geometry calculations. It defines classes for the +quantities that such calculations use, such as scalars, 3-vectors, rotation matrices, and +quaternions, and it lets every one of them stand for an arbitrary array of such quantities +at once. A single :class:`~polymath.Vector3` can hold one vector or a million of them, and +the code that operates on it is written the same way in either case. + +The package was written for the OOPS library of the PDS Ring-Moon Systems Node, where it +describes the geometry of planetary images: the line of sight through every pixel, the time +each photon arrived, and the rotation between the camera and the sky. Any calculation that +combines arrays of vectors and matrices, tracks which elements are undefined, or needs +derivatives carried through a chain of operations can use it in the same way. + +PolyMath adds four things to a NumPy array: + +* **Shape separate from item.** Each object distinguishes the axes that index its items + from the axes that make up each item. A 2x2 array of 3x3 matrices has a + :attr:`~polymath.Qube.shape` of ``(2, 2)`` and an :attr:`~polymath.Qube.item` of + ``(3, 3)``. Broadcasting applies to the shape only, so a :class:`~polymath.Scalar` + multiplies a :class:`~polymath.Vector3` without any reshaping or ``np.newaxis``. +* **Masks.** Every object carries a boolean mask marking the elements whose value is + undefined. Operations that would raise an error, such as dividing by zero or taking the + square root of a negative number, mask the result instead of failing. +* **Units.** An object can carry a :class:`~polymath.Unit`, which records what the values + measure and controls how they are presented. +* **Derivatives.** An object can carry named derivatives, which every arithmetic operation + and math function propagates automatically. + +Overview +======== + +A typical use of the package has four stages. + +.. mermaid:: + + flowchart LR + A["Construct
numbers, sequences,
NumPy arrays"] --> B["Compute
operators and methods,
broadcast over the shape"] + B --> C["Inspect
values, mask, derivs,
indexing, iteration"] + C --> D["Store
pickle"] + +1. **Construct** objects from Python numbers, nested sequences, or NumPy arrays, + optionally attaching a mask, a unit, or derivatives. :doc:`user_guide_objects` + describes this stage. +2. **Compute** with the ordinary arithmetic operators and the methods of each class. + Masks, units, and derivatives travel with the results. :doc:`user_guide_math`, + :doc:`user_guide_masks`, :doc:`user_guide_derivatives`, and :doc:`user_guide_units` + describe this stage. +3. **Inspect** the results through the :attr:`~polymath.Qube.values`, + :attr:`~polymath.Qube.mask`, and :attr:`~polymath.Qube.derivs` properties, by indexing, + or by iterating. :doc:`user_guide_indexing` describes this stage. +4. **Store** objects with the standard :mod:`pickle` module, which PolyMath extends with + compression. :doc:`user_guide_pickling` describes this stage. + +The Classes +=========== + +Every class derives from :class:`~polymath.Qube`, and the methods of +:class:`~polymath.Qube` are available on every object. What distinguishes the subclasses +is the shape of one item and the operations that make sense for it. + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Class + - Item shape + - Represents + * - :class:`~polymath.Scalar` + - ``()`` + - A number, either integer or floating-point. + * - :class:`~polymath.Boolean` + - ``()`` + - A True or False value. A subclass of :class:`~polymath.Scalar`. + * - :class:`~polymath.Vector` + - ``(n,)`` + - A vector of any length. + * - :class:`~polymath.Pair` + - ``(2,)`` + - A coordinate pair or 2-vector. A subclass of :class:`~polymath.Vector`. + * - :class:`~polymath.Vector3` + - ``(3,)`` + - A 3-vector. A subclass of :class:`~polymath.Vector`. + * - :class:`~polymath.Quaternion` + - ``(4,)`` + - A quaternion, usable as a rotation. A subclass of :class:`~polymath.Vector`. + * - :class:`~polymath.Polynomial` + - ``(n,)`` + - The coefficients of a polynomial in one variable, highest power first. A subclass + of :class:`~polymath.Vector`. + * - :class:`~polymath.Matrix` + - ``(m, n)`` + - A matrix of any size. + * - :class:`~polymath.Matrix3` + - ``(3, 3)`` + - A 3x3 rotation matrix. A subclass of :class:`~polymath.Matrix`. + * - :class:`~polymath.Qube` + - any + - The base class of all of the above. It is rarely constructed directly. + +The :class:`~polymath.Unit` class is not a :class:`~polymath.Qube`; it describes the unit +that any of the above can carry. See :doc:`user_guide_units`. + +Installation +============ + +PolyMath requires Python 3.11 or later and runs on Linux, macOS, and Windows. Install it +from PyPI: + +.. code-block:: sh + + pip install rms-polymath + +This also installs its two dependencies: NumPy 2.0 or later, and ``rms-fpzip``, the +floating-point compressor used when objects are pickled. The package reads no environment +variables, needs no configuration files, and requires no external data. + +Confirm the installation by printing the version: + +.. code-block:: sh + + python -c "import polymath; print(polymath.__version__)" + +Importing +========= + +Every class is available from the top-level package: + +.. code-block:: python + + from polymath import (Boolean, Matrix, Matrix3, Pair, Polynomial, Quaternion, Qube, + Scalar, Unit, Vector, Vector3) + +The type aliases described in :doc:`user_guide_typing` live in :mod:`polymath.typedefs`. + +A First Calculation +=================== + +The following multiplies three speeds by one direction to obtain three velocities, then +takes their lengths. The :class:`~polymath.Scalar` has a shape of ``(3,)`` and the +:class:`~polymath.Vector3` has an empty shape, so the two broadcast together, and the +product is a :class:`~polymath.Vector3` with a shape of ``(3,)`` and an item of ``(3,)``. +No index bookkeeping is needed at any step. + +.. code-block:: python + + >>> from polymath import Scalar, Vector3 + >>> speed = Scalar([1., 2., 3.]) + >>> direction = Vector3([0.6, 0.8, 0.]) + >>> velocity = speed * direction + >>> velocity + Vector3([0.6 0.8 0. ] + [1.2 1.6 0. ] + [1.8 2.4 0. ]) + >>> velocity.shape, velocity.item + ((3,), (3,)) + >>> velocity.norm() + Scalar(1. 2. 3.) + +Global Settings +=============== + +PolyMath has no configuration files or environment variables. Two settings apply +process-wide, and each is set by calling a method on :class:`~polymath.Qube`. + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Setting + - Effect + * - :meth:`~polymath.Qube.prefer_builtins` + - When True, reductions and comparisons that produce a single unmasked value return a + Python ``float``, ``int``, or ``bool`` rather than a PolyMath object. The default + is False. See :doc:`user_guide_math`. + * - :meth:`~polymath.Qube.set_default_pickle_digits` + - The floating-point precision used when pickling any object that has no setting of + its own. The default preserves full double precision. See + :doc:`user_guide_pickling`. + +A per-object setting made with :meth:`~polymath.Qube.set_pickle_digits` takes precedence +over the global default, which in turn takes precedence over the built-in default. + +Neither setting is synchronized. PolyMath objects are safe to read from several threads +at once, but modifying one while another thread reads it is not, and neither is changing +a global setting once other threads are running. Set them before starting any threads. diff --git a/docs/user_guide/user_guide_masks.rst b/docs/user_guide/user_guide_masks.rst new file mode 100644 index 0000000..9c07aa5 --- /dev/null +++ b/docs/user_guide/user_guide_masks.rst @@ -0,0 +1,199 @@ +===== +Masks +===== + +Every PolyMath object carries a boolean mask that marks the elements whose value is +undefined. The mask is what lets a calculation proceed over an entire array even when +some elements have no meaningful answer, such as lines of sight that miss a planet, and it +is what prevents the warnings and exceptions that NumPy would raise for a division by +zero or the square root of a negative number. + +What a Mask Means +================= + +The :attr:`~polymath.Qube.mask` property is a single ``False`` when nothing is masked, a +single ``True`` when everything is masked, and otherwise a boolean NumPy array with the +object's :attr:`~polymath.Qube.shape`. It never has item axes; a +:class:`~polymath.Vector3` is masked as a whole vector, not component by component. +:attr:`~polymath.Qube.antimask` is its logical inverse, which is convenient as an index +that selects the valid elements. + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Boolean, Scalar, Vector3 + >>> x = Scalar([1., 2., 3.], mask=[False, True, False]) + >>> x + Scalar(1.0 -- 3.0; mask) + >>> x.mask + array([False, True, False]) + >>> x.antimask + array([ True, False, True]) + >>> Scalar([1., 2., 3.]).mask + False + +A masked element still has a value in the underlying array, which +:attr:`~polymath.Qube.values` returns unchanged. Treat that value as meaningless. +:attr:`~polymath.Qube.mvals` returns a :class:`numpy.ma.MaskedArray` that hides it, and +:meth:`~polymath.Qube.without_mask` returns a copy with the mask removed, which exposes +it. Each object also has a :attr:`~polymath.Qube.default` value, chosen so as not to break +arithmetic, which is what masked elements hold after an object is unpickled or restored by +:meth:`~polymath.Qube.unshrink`. + +Under normal circumstances a masked value means "this value does not exist". This +resembles NumPy's not-a-number, but the rules differ: + +* Two masked values of the same class compare equal, and a masked value never equals an + unmasked one. +* Any unary or binary operation involving a masked element produces a masked element. +* Reductions ignore masked elements. :meth:`~polymath.Scalar.max` returns the maximum of + the unmasked values, :meth:`~polymath.Qube.mean` averages them, and + :meth:`~polymath.Qube.all` is True if every unmasked value is True. + +.. code-block:: python + + >>> y = Scalar([4., -1., 9.]).mask_where_lt(0) + >>> y + 1 + Scalar(5.0 -- 10.0; mask) + >>> y.max() + Scalar(9.0) + >>> Scalar([1., 2.], mask=[True, False]) == Scalar([3., 2.], mask=[True, False]) + Boolean( True True) + +Where Masks Come From +===================== + +A mask is set at construction with the ``mask`` argument, and it appears automatically +when an operation has no defined result: + +.. code-block:: python + + >>> Scalar([4., -1., 9.]).sqrt() + Scalar(2.0 -- 3.0; mask) + >>> Scalar([1., 2.]) / Scalar([0., 1.]) + Scalar(-- 2.0; mask) + +A family of methods masks elements by value. Each returns a copy, and each accepts a +``replace`` argument giving a value to store in the newly masked elements. + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Method + - Masks the elements that are + * - :meth:`~polymath.Qube.mask_where` + - True in a given mask or :class:`~polymath.Boolean`. + * - :meth:`~polymath.Qube.mask_where_eq`, :meth:`~polymath.Qube.mask_where_ne` + - Equal to, or not equal to, a value. + * - :meth:`~polymath.Qube.mask_where_lt`, :meth:`~polymath.Qube.mask_where_le`, + :meth:`~polymath.Qube.mask_where_gt`, :meth:`~polymath.Qube.mask_where_ge` + - Below or above a limit. + * - :meth:`~polymath.Qube.mask_where_between`, + :meth:`~polymath.Qube.mask_where_outside` + - Inside or outside a range, with a ``mask_endpoints`` option. + * - :meth:`~polymath.Qube.clip` + - Outside a range, after clipping them to it; pass ``remask=False`` to clip without + masking. + +.. code-block:: python + + >>> x = Scalar([1., 2., 3., 4., 5.]) + >>> x.mask_where(x > 4) + Scalar(1.0 2.0 3.0 4.0 --; mask) + >>> x.mask_where_between(2., 4.) + Scalar(1.0 2.0 -- 4.0 5.0; mask) + >>> x.mask_where_outside(2., 4.) + Scalar(-- 2.0 3.0 4.0 --; mask) + >>> Scalar([-2., 0.5, 3.]).clip(0., 1.) + Scalar(-- 0.5 --; mask) + >>> Scalar([-2., 0.5, 3.]).clip(0., 1., remask=False) + Scalar(0. 0.5 1. ) + +A mask cannot be assigned directly. :meth:`~polymath.Qube.remask` returns a shallow copy +with a replacement mask, :meth:`~polymath.Qube.remask_or` returns one with the given mask +added to the existing one, and :meth:`~polymath.Qube.as_all_masked` masks everything. +Every class has a constant holding a single masked value, such as +:attr:`~polymath.Scalar.MASKED` and :attr:`~polymath.Vector3.MASKED`. + +.. code-block:: python + + >>> x.remask([True, False, False, False, False]) + Scalar(-- 2.0 3.0 4.0 5.0; mask) + >>> Vector3.MASKED + Vector3(-- -- --; mask) + +Inspecting a Mask +================= + +:meth:`~polymath.Qube.count_masked`, :meth:`~polymath.Qube.count_unmasked`, and +:meth:`~polymath.Qube.is_all_masked` summarize the mask. +:meth:`~polymath.Qube.expand_mask` returns a copy whose mask is a full array even if +nothing is masked, and :meth:`~polymath.Qube.collapse_mask` returns one whose mask is a +single boolean when the array allows it. :meth:`~polymath.Qube.as_mask_where_nonzero`, +:meth:`~polymath.Qube.as_mask_where_zero`, +:meth:`~polymath.Qube.as_mask_where_nonzero_or_masked`, and +:meth:`~polymath.Qube.as_mask_where_zero_or_masked` derive a NumPy boolean array from the +values and the mask together, for use in indexing NumPy arrays. + +.. code-block:: python + + >>> y = Scalar([4., -1., 9.]).mask_where_lt(0) + >>> print(y.count_masked(), y.count_unmasked(), y.is_all_masked()) + 1 2 False + +Three-Valued Logic +================== + +Sometimes a masked value is better read as "unknown" than as "nonexistent". The methods +whose names begin with ``tvl_`` follow the rules of three-valued logic, in which a result +is True or False whenever the unknown elements could not change it and is masked only +when they could. + +* :meth:`~polymath.Qube.tvl_and` is False if either operand is False, even when the other + is masked. +* :meth:`~polymath.Qube.tvl_or` is True if either operand is True, even when the other is + masked. +* :meth:`~polymath.Qube.tvl_all` is True only if every value is True, False if any value + is False, and masked if the only values are True and unknown. +* :meth:`~polymath.Qube.tvl_any` is True if any value is True, False if every value is + False, and masked if the only values are False and unknown. +* :meth:`~polymath.Qube.tvl_eq`, :meth:`~polymath.Qube.tvl_ne`, + :meth:`~polymath.Qube.tvl_lt`, :meth:`~polymath.Qube.tvl_le`, + :meth:`~polymath.Qube.tvl_gt`, and :meth:`~polymath.Qube.tvl_ge` are the comparisons, + masked wherever either operand is masked. + +Compare the ordinary ``==``, which treats a masked element as equal to another masked +element and unequal to anything else: + +.. code-block:: python + + >>> Boolean([True, False], mask=[True, False]).tvl_and(False) + Boolean(False False) + >>> Boolean([True, True], mask=[True, False]).tvl_all() + Boolean(--; mask) + >>> Scalar([1., 2.], mask=[True, False]).tvl_eq(1.) + Boolean(-- False; mask) + >>> Scalar([1., 2.], mask=[True, False]) == 1. + Boolean(False False) + +Shrinking +========= + +When most of an object is masked, the masked elements still cost time in every operation. +:meth:`~polymath.Qube.shrink` returns a one-dimensional, read-only copy holding only the +elements selected by an antimask, and :meth:`~polymath.Qube.unshrink` restores the +original shape afterward, masking everything the antimask excluded. A calculation +performed on shrunken objects gives the same result as one performed on the originals, +provided that every object involved is shrunk by the same antimask. + +.. code-block:: python + + >>> big = Scalar(np.arange(10.)).mask_where(np.arange(10) % 2 == 0) + >>> big + Scalar(-- 1.0 -- 3.0 -- 5.0 -- 7.0 -- 9.0; mask) + >>> small = big.shrink(big.antimask) + >>> small + Scalar(1. 3. 5. 7. 9.) + >>> (small * 2).unshrink(big.antimask, big.shape) + Scalar(-- 2.0 -- 6.0 -- 10.0 -- 14.0 -- 18.0; mask) diff --git a/docs/user_guide/user_guide_math.rst b/docs/user_guide/user_guide_math.rst new file mode 100644 index 0000000..b3df7a7 --- /dev/null +++ b/docs/user_guide/user_guide_math.rst @@ -0,0 +1,318 @@ +============================= +Arithmetic and Math Functions +============================= + +PolyMath objects support the ordinary Python operators, and each class adds the functions +that make sense for what it represents. Every operation broadcasts over the shape, masks +any element it cannot compute, checks that units are compatible, and propagates +derivatives. This chapter describes the operations themselves; the chapters that follow +describe what happens to masks, derivatives, and units along the way. + +Operators +========= + +The arithmetic operators ``+``, ``-``, ``*``, ``/``, ``//``, ``%``, and ``**`` are defined +along with their in-place forms, and the operands can be PolyMath objects, Python numbers, +NumPy arrays, or nested sequences in any combination. + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Boolean, Matrix, Matrix3, Pair, Scalar, Vector, Vector3 + >>> x = Scalar([1., 4., 9.]) + >>> x + 1 + Scalar( 2. 5. 10.) + >>> x * 2 + Scalar( 2. 8. 18.) + >>> x ** 0.5 + Scalar(1. 2. 3.) + >>> x // 2 + Scalar(0. 2. 4.) + >>> x + np.array([10., 20., 30.]) + Scalar(11. 24. 39.) + >>> np.array([10., 20., 30.]) + x + Scalar(11. 24. 39.) + +What ``*`` means depends on the classes involved. A :class:`~polymath.Scalar` scales +anything. A :class:`~polymath.Matrix` times a :class:`~polymath.Vector` is a matrix-vector +product, a :class:`~polymath.Matrix` times a :class:`~polymath.Matrix` is a matrix product, +and a :class:`~polymath.Matrix3` times a :class:`~polymath.Vector3` rotates the vector. Two +vectors cannot be multiplied with ``*``, because the product would be ambiguous; use +:meth:`~polymath.Vector.dot`, :meth:`~polymath.Vector.cross`, or +:meth:`~polymath.Vector.element_mul` instead. Vectors add and subtract as usual. + +.. code-block:: python + + >>> Vector3.XAXIS * 2 + Vector3(2. 0. 0.) + >>> Vector3.XAXIS + Vector3.YAXIS + Vector3(1. 1. 0.) + >>> Matrix([[2., 0.], [0., 4.]]) * Vector([1., 1.]) + Vector(2. 4.) + >>> Vector3([1., 2., 3.]).element_mul(Vector3([2., 2., 2.])) + Vector3(2. 4. 6.) + +The comparison operators ``==`` and ``!=`` work for every class and return a +:class:`~polymath.Boolean`. The ordering operators ``<``, ``<=``, ``>``, and ``>=`` are +defined for :class:`~polymath.Scalar` and :class:`~polymath.Boolean` only. When both +operands are single values, a comparison returns a Python ``bool`` instead. + +.. code-block:: python + + >>> x > 3 + Boolean(False True True) + >>> x == 4 + Boolean(False True False) + >>> Vector3.XAXIS == Vector3.XAXIS + True + +:class:`~polymath.Boolean` objects combine with ``&``, ``|``, ``^``, and ``~``, and +:meth:`~polymath.Qube.any` and :meth:`~polymath.Qube.all` reduce them. + +.. code-block:: python + + >>> Boolean([True, False]) & Boolean([True, True]) + Boolean( True False) + >>> ~Boolean([True, False]) + Boolean(False True) + >>> (x > 0).all() + Boolean(True) + +:func:`abs` and :func:`len` work as expected, with :func:`len` counting along the first +axis of the shape. + +.. code-block:: python + + >>> abs(Scalar([-1., 2.])) + Scalar(1. 2.) + >>> len(x) + 3 + +Scalar Functions +================ + +:class:`~polymath.Scalar` provides the common math functions as methods. Each takes a +``recursive`` keyword, described in :doc:`user_guide_derivatives`, and those that can fail +for some inputs mask the elements where they do. + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Methods + - Purpose + * - :meth:`~polymath.Scalar.sin`, :meth:`~polymath.Scalar.cos`, + :meth:`~polymath.Scalar.tan`, :meth:`~polymath.Scalar.arcsin`, + :meth:`~polymath.Scalar.arccos`, :meth:`~polymath.Scalar.arctan`, + :meth:`~polymath.Scalar.arctan2` + - Trigonometry, in radians. The inverse functions mask inputs outside their domain. + * - :meth:`~polymath.Scalar.sqrt`, :meth:`~polymath.Scalar.log`, + :meth:`~polymath.Scalar.exp` + - Square root, natural logarithm, and exponential. The first two mask the inputs for + which they are undefined. + * - :meth:`~polymath.Scalar.abs`, :meth:`~polymath.Scalar.sign`, + :meth:`~polymath.Scalar.int`, :meth:`~polymath.Scalar.frac` + - Absolute value, sign, integer part rounded toward negative infinity, and + fractional part. + * - :meth:`~polymath.Scalar.reciprocal` + - One over the value, masking zeros. + * - :meth:`~polymath.Scalar.max`, :meth:`~polymath.Scalar.min`, + :meth:`~polymath.Scalar.argmax`, :meth:`~polymath.Scalar.argmin`, + :meth:`~polymath.Scalar.median`, :meth:`~polymath.Scalar.sort` + - Reductions over one axis or over the whole shape, ignoring masked elements. + * - :meth:`~polymath.Scalar.maximum`, :meth:`~polymath.Scalar.minimum` + - Element-by-element maximum and minimum of several objects. + * - :meth:`~polymath.Scalar.solve_quadratic`, :meth:`~polymath.Scalar.eval_quadratic` + - Roots and values of a quadratic given its three coefficients. + +.. code-block:: python + + >>> x.sqrt() + Scalar(1. 2. 3.) + >>> Scalar([1.5, -2.5]).int() + Scalar( 1 -3) + >>> Scalar([1.5, -2.5]).frac() + Scalar(0.5 0.5) + >>> Scalar([3., 1., 2.]).max() + Scalar(3.0) + >>> Scalar([3., 1., 2.]).argmin() + Scalar(1) + >>> Scalar.maximum(Scalar([1., 5.]), Scalar([3., 2.])) + Scalar(3. 5.) + >>> Scalar.solve_quadratic(1., -3., 2.) + (Scalar(1.0), Scalar(2.0)) + +:meth:`~polymath.Qube.sum` and :meth:`~polymath.Qube.mean` are available on every class +and accept an ``axis``: + +.. code-block:: python + + >>> Scalar([[1., 2.], [3., 4.]]).sum(axis=0) + Scalar(4. 6.) + >>> Scalar([1., 2., 3.]).mean() + Scalar(2.0) + +Vector Functions +================ + +:class:`~polymath.Vector` and its subclasses provide the vector algebra. + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Methods + - Purpose + * - :meth:`~polymath.Vector.dot`, :meth:`~polymath.Vector.cross`, + :meth:`~polymath.Vector.ucross`, :meth:`~polymath.Vector.outer` + - Dot product, cross product, unit cross product, and outer product, the last of + which is a :class:`~polymath.Matrix`. + * - :meth:`~polymath.Vector.norm`, :meth:`~polymath.Vector.norm_sq`, + :meth:`~polymath.Vector.unit`, :meth:`~polymath.Vector.with_norm` + - Length, squared length, the unit vector in the same direction, and the vector + scaled to a given length. + * - :meth:`~polymath.Vector.sep`, :meth:`~polymath.Vector.perp`, + :meth:`~polymath.Vector.proj` + - The angle between two vectors, and the components of one vector perpendicular to + and projected onto another. + * - :meth:`~polymath.Vector.element_mul`, :meth:`~polymath.Vector.element_div` + - Element-by-element product and quotient. + +.. code-block:: python + + >>> Vector3([3., 4., 0.]).norm() + Scalar(5.0) + >>> Vector3([3., 4., 0.]).unit() + Vector3(0.6 0.8 0. ) + >>> Vector3.XAXIS.dot(Vector3([1., 1., 0.])) + Scalar(1.0) + >>> Vector3.XAXIS.cross(Vector3.YAXIS) + Vector3(0. 0. 1.) + >>> Vector3.XAXIS.sep(Vector3.YAXIS) + Scalar(1.5707963267948968) + >>> Vector3([1., 1., 0.]).perp(Vector3.XAXIS) + Vector3(0. 1. 0.) + +:class:`~polymath.Vector3` adds conversions between Cartesian coordinates and angles: +:meth:`~polymath.Vector3.from_ra_dec_length` and +:meth:`~polymath.Vector3.to_ra_dec_length` for right ascension and declination, +:meth:`~polymath.Vector3.from_cylindrical` and :meth:`~polymath.Vector3.to_cylindrical` +for cylindrical coordinates, and :meth:`~polymath.Vector3.longitude` and +:meth:`~polymath.Vector3.latitude`. :meth:`~polymath.Vector3.spin` rotates a vector about +an axis by an angle, and :meth:`~polymath.Vector3.offset_angles` gives the angular offsets +of one vector from another. + +.. code-block:: python + + >>> Vector3([1., 1., 0.]).longitude() + Scalar(0.7853981633974483) + >>> Vector3([1., 1., 0.]).to_ra_dec_length() + (Scalar(0.7853981633974483), Scalar(0.0), Scalar(1.4142135623730951)) + +:class:`~polymath.Pair` adds :meth:`~polymath.Pair.swapxy`, :meth:`~polymath.Pair.rot90`, +:meth:`~polymath.Pair.angle`, and :meth:`~polymath.Pair.clip2d`: + +.. code-block:: python + + >>> Pair([1., 2.]).swapxy() + Pair(2. 1.) + >>> Pair([1., 0.]).rot90() + Pair( 0. -1.) + >>> Pair([1., 1.]).angle() + Scalar(0.7853981633974483) + +Matrices and Rotations +====================== + +:class:`~polymath.Matrix` provides :meth:`~polymath.Matrix.transpose` (also available as +the :attr:`~polymath.Matrix.T` property), :meth:`~polymath.Matrix.inverse`, +:meth:`~polymath.Matrix.solve` for linear systems, :meth:`~polymath.Matrix.unitary` for +the nearest orthonormal matrix, :meth:`~polymath.Matrix.is_diagonal`, and +:meth:`~polymath.Matrix.identity`. + +.. code-block:: python + + >>> m = Matrix([[2., 0.], [0., 4.]]) + >>> m.inverse() + Matrix([0.5 0. ] + [0. 0.25]) + >>> m.solve(Vector([2., 4.])) + Vector(1. 1.) + +:class:`~polymath.Matrix3` represents rotations. :meth:`~polymath.Matrix3.x_rotation`, +:meth:`~polymath.Matrix3.y_rotation`, :meth:`~polymath.Matrix3.z_rotation`, and +:meth:`~polymath.Matrix3.axis_rotation` build a rotation about one axis by an angle in +radians; :meth:`~polymath.Matrix3.pole_rotation` builds one from the right ascension and +declination of a pole; :meth:`~polymath.Matrix3.from_euler` builds one from three Euler +angles, which :meth:`~polymath.Matrix3.to_euler` recovers; and +:meth:`~polymath.Matrix3.twovec` builds the rotation that aligns two given vectors with +two axes. Apply a rotation with ``*`` or :meth:`~polymath.Matrix3.rotate`, and apply its +inverse with :meth:`~polymath.Matrix3.unrotate`. + +.. code-block:: python + + >>> r = Matrix3.z_rotation(np.pi / 2) + >>> r * Vector3.XAXIS + Vector3(6.123234e-17 1.000000e+00 0.000000e+00) + >>> r.unrotate(Vector3.YAXIS) + Vector3(1.000000e+00 6.123234e-17 0.000000e+00) + >>> Matrix3.from_euler(0.1, 0.2, 0.3).to_euler() + (Scalar(0.10000000000000002), Scalar(0.2), Scalar(0.29999999999999993)) + +:class:`~polymath.Quaternion` represents the same rotations in four components. +:meth:`~polymath.Quaternion.from_rotation` builds one from an angle and an axis, and +:meth:`~polymath.Quaternion.to_rotation` reverses it; +:meth:`~polymath.Quaternion.to_matrix3` and :meth:`~polymath.Quaternion.from_matrix3` +convert to and from a :class:`~polymath.Matrix3`; :meth:`~polymath.Quaternion.conj` is the +conjugate; and ``*`` is the quaternion product. + +.. code-block:: python + + >>> from polymath import Quaternion + >>> q = Quaternion.from_rotation(np.pi / 2, Vector3.ZAXIS) + >>> q + Quaternion(0.70710678 0. 0. 0.70710678) + >>> q.to_matrix3() + Matrix3([ 0. -1. 0.] + [ 1. 0. 0.] + [ 0. 0. 1.]) + >>> q * q.conj() + Quaternion(1. 0. 0. 0.) + +Polynomials +=========== + +A :class:`~polymath.Polynomial` holds coefficients in order of decreasing power. +:meth:`~polymath.Polynomial.eval` evaluates it, :meth:`~polymath.Polynomial.deriv` +differentiates it, and :meth:`~polymath.Polynomial.roots` finds its roots; the arithmetic +operators combine polynomials as polynomials. + +.. code-block:: python + + >>> from polymath import Polynomial + >>> p = Polynomial([1., -3., 2.]) + >>> p.eval(Scalar([0., 1., 2.])) + Scalar(2. 0. 0.) + >>> p.roots() + Scalar(1. 2.) + >>> p.deriv() + Polynomial( 2. -3.) + +Results as Python Numbers +========================= + +A result with an empty shape is still a PolyMath object, so that its mask, unit, and +derivatives are preserved. :meth:`~polymath.Qube.as_builtin` converts such an object to a +Python ``float``, ``int``, or ``bool`` when that loses nothing, many methods take a +``builtins`` keyword to make the same decision for one call, and the global setting +:meth:`~polymath.Qube.prefer_builtins` makes the reductions and comparisons return builtins +by default. + +.. code-block:: python + + >>> Scalar([1., 2.]).sum() + Scalar(3.0) + >>> Scalar([1., 2.]).sum().as_builtin() + 3.0 + >>> Scalar([1., 2.]).sum(builtins=True) + 3.0 diff --git a/docs/user_guide/user_guide_objects.rst b/docs/user_guide/user_guide_objects.rst new file mode 100644 index 0000000..aa661c4 --- /dev/null +++ b/docs/user_guide/user_guide_objects.rst @@ -0,0 +1,353 @@ +================================= +Objects, Shapes, and Broadcasting +================================= + +Every PolyMath class derives from :class:`~polymath.Qube`, and every object wraps a NumPy +array together with a mask, an optional unit, and an optional set of derivatives. This +chapter covers how objects are built and how their axes are organized, which is the +foundation for everything that follows. + +Constructing Objects +==================== + +Each class is constructed from anything NumPy can turn into an array: a number, a nested +list or tuple, a NumPy array, or another PolyMath object. + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Boolean, Matrix3, Pair, Scalar, Vector3 + >>> Scalar(3) + Scalar(3) + >>> Scalar([1, 2, 3]) + Scalar(1 2 3) + >>> Vector3([1., 2., 3.]) + Vector3(1. 2. 3.) + >>> Pair([3., 4.]) + Pair(3. 4.) + >>> Boolean([True, False]) + Boolean( True False) + >>> Matrix3([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + Matrix3([1. 0. 0.] + [0. 1. 0.] + [0. 0. 1.]) + +The trailing axes of the input become the item; whatever precedes them becomes the shape. +A NumPy array of shape ``(4, 3)`` therefore constructs four 3-vectors: + +.. code-block:: python + + >>> v = Vector3(np.zeros((4, 3))) + >>> v.shape + (4,) + >>> v.item + (3,) + +Each class checks its input. A :class:`~polymath.Vector3` requires a last axis of length +three, a :class:`~polymath.Matrix3` requires two trailing axes of length three, and a +:class:`~polymath.Boolean` accepts only truth values. Integer input to a class that holds +floating-point values only, such as :class:`~polymath.Vector3` or +:class:`~polymath.Matrix3`, is converted to floating point, as the +:class:`~polymath.Matrix3` example above shows. A :class:`~polymath.Scalar` keeps integers +as integers. + +The constructor takes keyword arguments for the other parts of an object. ``mask`` marks +undefined elements, ``unit`` attaches a :class:`~polymath.Unit`, and ``derivs`` attaches a +dictionary of derivatives; each has its own chapter. + +.. code-block:: python + + >>> from polymath import Unit + >>> Scalar([1., 2., 3.], mask=[False, True, False]) + Scalar(1.0 -- 3.0; mask) + >>> Scalar([1., 2.], unit=Unit.KM) + Scalar(1. 2.; km) + +Every class also has a static conversion method named after it, such as +:meth:`~polymath.Scalar.as_scalar`, :meth:`~polymath.Vector3.as_vector3`, and +:meth:`~polymath.Pair.as_pair`. These return the argument unchanged when it is already of +the right class, which makes them the cheapest way to accept either a PolyMath object or a +plain value: + +.. code-block:: python + + >>> Scalar.as_scalar(5) + Scalar(5) + >>> Vector3.as_vector3([1, 2, 3]) + Vector3(1. 2. 3.) + +Other constructors build objects from components or fill them with a constant. +:meth:`~polymath.Vector3.from_scalars` assembles a vector from its components, each of +which can itself be an array, and :meth:`~polymath.Vector.to_scalars` reverses it. The +class methods :meth:`~polymath.Qube.zeros`, :meth:`~polymath.Qube.ones`, and +:meth:`~polymath.Qube.filled` create objects of a given shape. + +.. code-block:: python + + >>> Vector3.from_scalars(1., 2., 3.) + Vector3(1. 2. 3.) + >>> Vector3.from_scalars(np.arange(3.), 0., 1.) + Vector3([0. 0. 1.] + [1. 0. 1.] + [2. 0. 1.]) + >>> Vector3([1., 2., 3.]).to_scalars() + (Scalar(1.0), Scalar(2.0), Scalar(3.0)) + >>> Vector3.zeros((2,)) + Vector3([0. 0. 0.] + [0. 0. 0.]) + >>> Vector3.filled((2,), (1., 2., 3.)) + Vector3([1. 2. 3.] + [1. 2. 3.]) + +Each class defines read-only constants for its most common values, such as +:attr:`~polymath.Scalar.ZERO`, :attr:`~polymath.Scalar.PI`, +:attr:`~polymath.Vector3.XAXIS`, :attr:`~polymath.Matrix3.IDENTITY`, and +:attr:`~polymath.Boolean.TRUE`. Each class also has a constant holding a single masked +value, such as :attr:`~polymath.Scalar.MASKED`. + +Shape Versus Item +================= + +NumPy has one notion of shape. PolyMath splits it in two. The +:attr:`~polymath.Qube.shape` of an object is the shape of the array of items it holds, and +its :attr:`~polymath.Qube.item` is the shape of one item. The underlying NumPy array, +available through :attr:`~polymath.Qube.values`, has the two concatenated. + +.. code-block:: python + + >>> m = Matrix3(np.zeros((2, 2, 3, 3))) + >>> m.shape + (2, 2) + >>> m.item + (3, 3) + >>> m.values.shape + (2, 2, 3, 3) + +Several properties describe these axes. + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Property + - Meaning + * - :attr:`~polymath.Qube.shape` + - The leading axes, which index the items. + * - :attr:`~polymath.Qube.ndims` + - The number of leading axes. :attr:`~polymath.Qube.ndim` is a synonym. + * - :attr:`~polymath.Qube.size` + - The number of items, which is the product of the shape. + * - :attr:`~polymath.Qube.item` + - The trailing axes, which make up one item. + * - :attr:`~polymath.Qube.rank` + - The number of item axes. + * - :attr:`~polymath.Qube.isize` + - The number of elements in one item. + * - :attr:`~polymath.Qube.numer`, :attr:`~polymath.Qube.denom` + - The item axes split into a numerator and a denominator; see + :doc:`user_guide_derivatives`. For an object without a denominator, + :attr:`~polymath.Qube.numer` equals :attr:`~polymath.Qube.item` and + :attr:`~polymath.Qube.denom` is empty. + +An object with an empty shape holds a single item. For a :class:`~polymath.Scalar` or +:class:`~polymath.Boolean` with an empty shape, :attr:`~polymath.Qube.values` is a Python +number rather than a NumPy array: + +.. code-block:: python + + >>> Scalar(3.).values + 3.0 + >>> Scalar([1, 2]).values + array([1, 2]) + +Reading Values Back +=================== + +:attr:`~polymath.Qube.values`, or its synonym :attr:`~polymath.Qube.vals`, returns the +data as a NumPy array in the standard units of kilometers, seconds, and radians regardless +of the unit the object carries. :attr:`~polymath.Qube.mvals` returns the same data as a +:class:`numpy.ma.MaskedArray` whose mask is the object's mask, and +:meth:`~polymath.Qube.into_unit` returns the values converted into the object's unit. + +.. code-block:: python + + >>> x = Scalar([1., 2., 3.], mask=[False, True, False]) + >>> x.mvals + masked_array(data=[1.0, --, 3.0], + mask=[False, True, False], + fill_value=1e+20) + +The string form of an object, produced by :meth:`~polymath.Qube.__str__`, shows the class +name and the values, followed by a semicolon and any suffixes that apply: the denominator +shape, the word ``mask`` if any element is masked, the unit, and the names of the +derivatives. Masked elements print as ``--``. + +Reshaping +========= + +The methods :meth:`~polymath.Qube.reshape`, :meth:`~polymath.Qube.flatten`, +:meth:`~polymath.Qube.swap_axes`, :meth:`~polymath.Qube.roll_axis`, and +:meth:`~polymath.Qube.move_axis` rearrange the leading axes and leave the items alone. +Each returns a shallow copy that shares memory with the original, and each applies the +same change to the derivatives. + +.. code-block:: python + + >>> a = Scalar(np.arange(6.)) + >>> a.reshape((2, 3)) + Scalar([0. 1. 2.] + [3. 4. 5.]) + >>> a.reshape((2, 3)).flatten() + Scalar(0. 1. 2. 3. 4. 5.) + >>> b = Scalar(np.arange(24.).reshape(2, 3, 4)) + >>> b.swap_axes(0, 2).shape + (4, 3, 2) + >>> b.roll_axis(2).shape + (4, 2, 3) + >>> b.move_axis(0, -1).shape + (3, 4, 2) + +:meth:`~polymath.Qube.stack` joins several objects along a leading axis: + +.. code-block:: python + + >>> from polymath import Qube + >>> Qube.stack(Vector3.XAXIS, Vector3.YAXIS) + Vector3([1. 0. 0.] + [0. 1. 0.]) + +Broadcasting +============ + +When two objects are combined, their shapes are broadcast together following the NumPy +rules, described at https://numpy.org/doc/stable/user/basics.broadcasting.html. The item +axes never participate, so no reshaping is needed to combine objects whose items differ. +A :class:`~polymath.Scalar` of shape ``(2,)`` times a single :class:`~polymath.Vector3` +gives two vectors: + +.. code-block:: python + + >>> Scalar([1., 2.]) * Vector3.XAXIS + Vector3([1. 0. 0.] + [2. 0. 0.]) + +Two rotation matrices of shape ``(2,)`` applied to vectors of shape ``(5, 1)`` give +vectors of shape ``(5, 2)``: + +.. code-block:: python + + >>> rotation = Matrix3.x_rotation(np.array([0., np.pi / 2])) + >>> v = Vector3(np.zeros((5, 1, 3)) + [0., 1., 0.]) + >>> (rotation * v).shape + (5, 2) + +:meth:`~polymath.Qube.broadcasted_shape` reports the shape an operation will produce +without performing it, and :meth:`~polymath.Qube.broadcast_to` and +:meth:`~polymath.Qube.broadcast` expand objects explicitly. Broadcast objects share memory +with their source, so they are read-only. + +.. code-block:: python + + >>> Qube.broadcasted_shape(rotation, v) + (5, 2) + >>> Scalar(1.).broadcast_to((3,)) + Scalar(1. 1. 1.) + >>> Scalar(1.).broadcast_to((3,)).readonly + True + +Converting Between Classes +========================== + +:meth:`~polymath.Qube.cast` reinterprets an object as another class with a compatible item +shape, and :meth:`~polymath.Qube.as_this_type` converts an argument to the class of the +object it is called on. Classes with fixed items also convert on construction, so passing +a :class:`~polymath.Vector` with three components to the :class:`~polymath.Vector3` +constructor works. + +.. code-block:: python + + >>> from polymath import Matrix, Vector + >>> Vector([1., 2., 3.]).cast(classes=Vector3) + Vector3(1. 2. 3.) + >>> Vector3.XAXIS.as_this_type(Vector([4., 5., 6.])) + Vector3(4. 5. 6.) + +Vectors and matrices convert into each other. :meth:`~polymath.Vector.as_column`, +:meth:`~polymath.Vector.as_row`, and :meth:`~polymath.Vector.as_diagonal` turn a vector +into a matrix; :meth:`~polymath.Matrix.row_vector`, :meth:`~polymath.Matrix.row_vectors`, +:meth:`~polymath.Matrix.column_vector`, and :meth:`~polymath.Matrix.column_vectors` +extract vectors from a matrix; and :meth:`~polymath.Vector.to_scalar` and +:meth:`~polymath.Matrix.to_scalar` extract one component. + +.. code-block:: python + + >>> Vector([1., 2.]).as_column() + Matrix([1.] + [2.]) + >>> Matrix([[1., 2.], [3., 4.]]).column_vectors() + (Vector(1. 3.), Vector(2. 4.)) + >>> Vector3([1., 2., 3.]).to_scalar(1) + Scalar(2.0) + +The numeric type of an object changes with :meth:`~polymath.Qube.as_float`, +:meth:`~polymath.Qube.as_int`, and :meth:`~polymath.Qube.as_bool`, and +:meth:`~polymath.Qube.is_float`, :meth:`~polymath.Qube.is_int`, and +:meth:`~polymath.Qube.is_bool` report it. Converting a :class:`~polymath.Scalar` to truth +values yields a :class:`~polymath.Boolean`, where zero is False and anything else is True. + +.. code-block:: python + + >>> Scalar([1, 2]).as_float() + Scalar(1. 2.) + >>> Scalar([1.7, 2.2]).as_int() + Scalar(1 2) + >>> Scalar([0, 2]).as_bool() + Boolean(False True) + +Read-Only Objects +================= + +Objects are writable when constructed. :meth:`~polymath.Qube.as_readonly` makes an object +and its derivatives read-only, after which any attempt to assign into it raises +:class:`ValueError`. The :attr:`~polymath.Qube.readonly` property reports the state. There +is no way back; :meth:`~polymath.Qube.copy` returns a writable copy instead, and +:meth:`~polymath.Qube.clone` returns a shallow copy that keeps the read-only state. + +.. code-block:: python + + >>> r = Scalar([1., 2.]).as_readonly() + >>> r.readonly + True + >>> r[0] = 5. + Traceback (most recent call last): + ... + ValueError: Scalar object is read-only + >>> r.copy().readonly + False + +An operation that shares memory with a read-only object, such as reshaping or +broadcasting, returns a read-only object; an operation that computes fresh values, such +as a negation, returns a writable one. The class constants are read-only, so a +calculation can never corrupt :attr:`~polymath.Vector3.ZERO` by accident. Read-only +status is useful in general whenever objects share memory, which is common, because it +stops one object from being modified by way of another. + +Custom Attributes +================= + +:meth:`~polymath.Qube.add_attr` attaches an attribute of your own to an object, so that +application-specific information can travel with it. The attribute is carried along by +every copy and clone but not by an operation that computes different values, and a name +beginning with ``d_d`` is reserved for derivatives. + +.. code-block:: python + + >>> obj = Scalar([1., 2.]) + >>> obj.add_attr('label', 'north pole') + Scalar(1. 2.) + >>> obj.label + 'north pole' + >>> obj.copy().label + 'north pole' + >>> hasattr(obj + 1, 'label') + False diff --git a/docs/user_guide/user_guide_pickling.rst b/docs/user_guide/user_guide_pickling.rst new file mode 100644 index 0000000..ce1ed03 --- /dev/null +++ b/docs/user_guide/user_guide_pickling.rst @@ -0,0 +1,96 @@ +==================== +Pickling and Storage +==================== + +PolyMath objects pickle with the standard :mod:`pickle` module, and the package takes the +opportunity to compress them. Objects such as image backplanes are numerous and large, so +the savings matter. + +.. code-block:: python + + >>> import pickle + >>> import numpy as np + >>> from polymath import Qube, Scalar + >>> x = Scalar(np.linspace(0., 1., 1000).reshape(10, 100)) + >>> y = pickle.loads(pickle.dumps(x)) + >>> (y == x).all() + Boolean(True) + +Only the unmasked elements are stored. After unpickling, the masked elements hold the +object's :attr:`~polymath.Qube.default` value: + +.. code-block:: python + + >>> z = pickle.loads(pickle.dumps(Scalar([1., 2., 3.], mask=[False, True, False]))) + >>> z + Scalar(1.0 -- 3.0; mask) + >>> z.values + array([1., 1., 3.]) + +How Values Are Compressed +========================= + +Integer arrays are compressed losslessly with BZ2 after being reduced to the fewest bytes +that cover their range. Arrays of truth values are packed into bits and then compressed +with BZ2. +Floating-point arrays are handled in one of four ways: + +1. Very small arrays are compressed with BZ2. +2. Constant arrays are stored as a single value plus a shape. +3. Values are divided by a constant, rounded to integers, and compressed as integers. +4. Values are compressed, with or without loss, by fpzip, which is especially effective + for arrays such as backplanes that vary smoothly from pixel to pixel. See + https://pypi.org/project/rms-fpzip. + +Choosing the Precision +====================== + +:meth:`~polymath.Qube.set_pickle_digits` sets the floating-point precision for one object, +and :meth:`~polymath.Qube.set_default_pickle_digits` sets the default for every object +that has no setting of its own. Both take the same two arguments, and +:meth:`~polymath.Qube.pickle_digits` and :meth:`~polymath.Qube.pickle_reference` report +the values in effect for an object. The built-in default preserves full double precision +with lossless fpzip compression. + +**digits** (``str``, ``int``, or ``float``): The number of digits to preserve. + +* ``"double"``: preserve full precision using lossless fpzip compression. +* ``"single"``: convert the array to single precision and then store it using lossless + fpzip compression. +* A number from 7 to 16, defining the number of significant digits to preserve. + +**reference** (``str`` or ``float``): How to interpret a numeric value of **digits**. + +* ``"fpzip"``: Use lossy fpzip compression, preserving the given number of digits. +* A number: Preserve every value to the same absolute precision, obtained by scaling the + number of **digits** by this value. For example, if **digits** is 8 and **reference** is + 100, every value is rounded to the nearest 1.e-6 before storage. This uses the third + method above, in which values are converted to integers for storage. +* ``"smallest"``: The absolute precision is ``10**(-digits)`` times the nonzero value + closest to zero. This guarantees that every value preserves at least the requested + number of digits, and it is reasonable when all values fall within a similar dynamic + range. +* ``"largest"``: The absolute precision is ``10**(-digits)`` times the value furthest from + zero. This suits arrays with a limited range of values, such as the components of a + unit vector or angles known to fall between zero and two pi, where the extra precision + of values that happen to fall close to zero is not needed. +* ``"mean"``: The absolute precision is ``10**(-digits)`` times the mean of the absolute + values. +* ``"median"``: The absolute precision is ``10**(-digits)`` times the median of the + absolute values. This is a good choice when a minority of values differ greatly from + the rest, such as noise spikes or undefined geometry, so that the precision is based on + the typical values. +* ``"logmean"``: The absolute precision is ``10**(-digits)`` times the logarithmic mean + of the absolute values. + +Either argument can also be a tuple of two values, in which case the second applies to +the derivatives of the object. + +.. code-block:: python + + >>> x.set_pickle_digits(8, 'fpzip') + >>> x.pickle_digits() + (8.0, 8.0) + >>> x.pickle_reference() + ('fpzip', 'fpzip') + >>> Qube.set_default_pickle_digits('double', 'fpzip') diff --git a/docs/user_guide/user_guide_typing.rst b/docs/user_guide/user_guide_typing.rst new file mode 100644 index 0000000..b3aef2a --- /dev/null +++ b/docs/user_guide/user_guide_typing.rst @@ -0,0 +1,100 @@ +================ +Type Annotations +================ + +The PolyMath package ships a ``py.typed`` marker and two stub files, one for the package +and one for :mod:`polymath.typedefs`, so a type checker such as mypy sees the signature of +every public class, method, and property when it checks code that imports PolyMath. +Nothing needs to be configured; installing the package is enough. Import from the package +itself, as every example in this guide does. The submodules that define the classes are +an implementation detail: an import such as one from a module named after a class is +not supported and carries no type information. + +The Type Aliases +================ + +The :mod:`polymath.typedefs` module supplements the stubs with aliases for use in your +own annotations. Each alias names everything the corresponding constructor accepts, which +is broader than the class itself. + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Alias + - Accepts + * - :data:`~polymath.typedefs.ScalarLike`, :data:`~polymath.typedefs.BooleanLike`, + :data:`~polymath.typedefs.QubeLike` + - A PolyMath object, a numeric array, a nested sequence of numbers, or a single + number. + * - :data:`~polymath.typedefs.VectorLike` + - A PolyMath object, a numeric array with one or more axes, or a nested sequence. + * - :data:`~polymath.typedefs.PairLike`, :data:`~polymath.typedefs.Vector3Like`, + :data:`~polymath.typedefs.QuaternionLike` + - A PolyMath object, a numeric array whose last axis has length two, three, or four + respectively, or a nested sequence. :data:`~polymath.typedefs.PairLike` also + accepts a single number, because :meth:`~polymath.Pair.as_pair` repeats a lone + value across both components. + * - :data:`~polymath.typedefs.MatrixLike` + - A PolyMath object, a numeric array with two or more axes, or a nested sequence. + * - :data:`~polymath.typedefs.Matrix3Like` + - A PolyMath object, a numeric array whose last two axes each have length three, or + a nested sequence. + * - :data:`~polymath.typedefs.ValsType` + - Anything the :attr:`~polymath.Qube.values` property can return: a number or a + NumPy array. + * - :data:`~polymath.typedefs.MaskType` + - Anything the :attr:`~polymath.Qube.mask` property can return: a boolean or a + boolean NumPy array. + * - :data:`~polymath.typedefs.IntValsType` + - The integral subset of :data:`~polymath.typedefs.ValsType`: an integer or an + integer NumPy array. + +Using an Alias +============== + +An alias is an ordinary runtime object, so it can be imported and used in an annotation +anywhere, without a ``TYPE_CHECKING`` guard: + +.. code-block:: python + + >>> from polymath import Scalar, Vector3 + >>> from polymath.typedefs import Vector3Like + >>> def speed(velocity: Vector3Like) -> Scalar: + ... return Vector3.as_vector3(velocity).norm() + >>> speed([3., 4., 0.]) + Scalar(5.0) + +Three caveats apply. First, every alias includes :class:`~polymath.Qube`, because each +constructor re-wraps any PolyMath object, so annotating a parameter as +:data:`~polymath.typedefs.Vector3Like` documents intent and rules out unrelated types such +as strings and dictionaries but does not restrict the argument to a +:class:`~polymath.Vector3`. Convert inside the function, as the example does. Second, the +arithmetic operators are declared once, on :class:`~polymath.Qube`, and their declared +result is a :class:`~polymath.Qube`, even though at runtime a product of a +:class:`~polymath.Vector3` and a :class:`~polymath.Scalar` is a +:class:`~polymath.Vector3`. A function that returns the result of an operation therefore +either declares :class:`~polymath.Qube` as its return type or passes the result through +the converter of the class it expects: + +.. code-block:: python + + >>> from polymath.typedefs import ScalarLike + >>> def scale(vector: Vector3Like, factor: ScalarLike) -> Vector3: + ... return Vector3.as_vector3(Vector3.as_vector3(vector) * Scalar.as_scalar(factor)) + >>> scale([1., 2., 3.], 2) + Vector3(2. 4. 6.) + +Third, a return type is ``Any`` wherever the docstring does not state one, so some +results need a cast or an ``isinstance`` check before a type checker will allow a +class-specific method to be called on them. + +Checking Your Code +================== + +Run mypy on your own modules as usual. The stubs are found through the installed package, +so no path configuration is needed: + +.. code-block:: sh + + mypy your_module.py diff --git a/docs/user_guide/user_guide_units.rst b/docs/user_guide/user_guide_units.rst new file mode 100644 index 0000000..d98e10c --- /dev/null +++ b/docs/user_guide/user_guide_units.rst @@ -0,0 +1,147 @@ +===== +Units +===== + +A PolyMath object can carry a :class:`~polymath.Unit`. Units make the meaning of a +quantity explicit, catch the addition of a distance to a time, and control how values are +presented, but they do not change how values are stored: the numbers inside every object +are always in the standard units of kilometers, seconds, and radians, or products and +powers of them. + +Values Are Always in Standard Units +=================================== + +Because storage is standardized, the ``unit`` argument of a constructor describes how to +present the values, not how to interpret them. An object constructed from the number 1 +with a unit of meters holds one kilometer and displays it as 1000 meters: + +.. code-block:: python + + >>> import numpy as np + >>> from polymath import Scalar, Unit + >>> d = Scalar([1., 2.], unit=Unit.M) + >>> d + Scalar(1000. 2000.; m) + >>> d.values + array([1., 2.]) + >>> d.into_unit() + array([1000., 2000.]) + +To construct an object from values expressed in some unit, convert them to standard units +first. :meth:`~polymath.Unit.from_this` converts a number from a unit into standard units, +and :meth:`~polymath.Unit.into_this` converts the other way. + +.. code-block:: python + + >>> angle = Scalar(Unit.DEG.from_this(90.), unit=Unit.DEG) + >>> angle + Scalar(90.0; deg) + >>> angle.sin() + Scalar(1.0) + >>> Unit.DEG.into_this(np.pi) + 180.0 + +Available Units +=============== + +The :class:`~polymath.Unit` class defines constants for the common units. + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Dimension + - Constants + * - Distance + - :attr:`~polymath.Unit.KM`, :attr:`~polymath.Unit.M`, :attr:`~polymath.Unit.CM`, + :attr:`~polymath.Unit.MM`, :attr:`~polymath.Unit.MICRON`, and their spelled-out + forms such as :attr:`~polymath.Unit.KILOMETERS`. + * - Time + - :attr:`~polymath.Unit.S`, :attr:`~polymath.Unit.MS`, :attr:`~polymath.Unit.MIN`, + :attr:`~polymath.Unit.H`, :attr:`~polymath.Unit.D`, and their spelled-out forms + such as :attr:`~polymath.Unit.SECONDS`. + * - Angle + - :attr:`~polymath.Unit.RAD`, :attr:`~polymath.Unit.MRAD`, :attr:`~polymath.Unit.DEG`, + :attr:`~polymath.Unit.ARCHOUR`, :attr:`~polymath.Unit.ARCMIN`, + :attr:`~polymath.Unit.ARCSEC`, :attr:`~polymath.Unit.REV`, + :attr:`~polymath.Unit.CYCLE`, and their spelled-out forms such as + :attr:`~polymath.Unit.DEGREES`. + * - Solid angle + - :attr:`~polymath.Unit.STER`. + * - None + - :attr:`~polymath.Unit.UNITLESS`, which is equivalent to a unit of None. + +Units multiply, divide, and raise to powers to form compound units, and +:meth:`~polymath.Unit.as_unit` looks up a unit by its standard name. + +.. code-block:: python + + >>> Unit.KM / Unit.S + Unit(km/s) + >>> (Unit.KM / Unit.S) ** 2 + Unit(km**2/s**2) + >>> Unit.as_unit('deg') + Unit(deg) + +A unit is defined by its exponents on distance, time, and angle and by a triple of +integers giving the exact conversion factor into standard units as a numerator, a +denominator, and a power of pi; both are attributes of the :class:`~polymath.Unit`. A +degree has exponents ``(0, 0, 1)`` and triple ``(1, 180, 1)``, meaning that the factor is +pi/180. The constructor takes the same two tuples and an optional name, so a unit that is +not predefined can be built. + +.. code-block:: python + + >>> Unit.DEG.exponents, Unit.DEG.triple + ((0, 0, 1), (1, 180, 1)) + +Units in Arithmetic +=================== + +Arithmetic propagates units. Multiplication and division combine them, addition and +subtraction require compatible ones, and the result of adding a distance in meters to one +in kilometers takes the unit of the left operand. + +.. code-block:: python + + >>> Scalar(2., unit=Unit.KM) * Scalar(3., unit=Unit.S) + Scalar(6.0; km*s) + >>> Scalar(1., unit=Unit.KM) + Scalar(1., unit=Unit.M) + Scalar(2.0; km) + >>> Scalar(1., unit=Unit.KM) + Scalar(1., unit=Unit.S) + Traceback (most recent call last): + ... + ValueError: Scalar "+" units are not compatible: km, s + +The trigonometric functions require an angle or a unitless value: + +.. code-block:: python + + >>> Scalar(1., unit=Unit.KM).sin() + Traceback (most recent call last): + ... + ValueError: Scalar.sin() unit is not compatible with an angle: km + +Changing the Unit +================= + +:attr:`~polymath.Qube.unit_`, or its synonym :attr:`~polymath.Qube.units`, returns the +unit, or None for a unitless object. :meth:`~polymath.Qube.set_unit` changes it in place, +which requires a writable object and a unit compatible with the existing one; +:meth:`~polymath.Qube.without_unit` returns a copy with no unit; +:meth:`~polymath.Qube.confirm_unit` raises an error unless the object has the given unit; +and :meth:`~polymath.Qube.is_unitless` reports whether there is one. + +.. code-block:: python + + >>> d = Scalar([1., 2.], unit=Unit.KM) + >>> d.unit_ + Unit(km) + >>> d.set_unit(Unit.M) + >>> d + Scalar(1000. 2000.; m) + >>> d.without_unit() + Scalar(1. 2.) + +:class:`~polymath.Boolean`, :class:`~polymath.Matrix3`, and :class:`~polymath.Quaternion` +never carry a unit, because a truth value or a rotation is dimensionless. diff --git a/pyproject.toml b/pyproject.toml index c6e5221..2c29ce8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,11 @@ dev = [ ] docs = [ "myst-parser", - "sphinx>=7", + # Sphinx 9 is the first release whose Python domain resolves a py:class reference -- + # which Napoleon generates for every docstring type -- to the py:data target that + # autodoc creates for a type alias. On Sphinx 8 and earlier every "*Like" alias in + # polymath.typedefs is left unresolved and the nitpicky -W build fails. + "sphinx>=9", "sphinxcontrib-mermaid", "sphinx-rtd-theme", ] @@ -110,16 +114,38 @@ fail_under = 90 [tool.mypy] strict = true disallow_subclassing_any = false +# stubtest discovers every module under the package and would compare each unannotated +# .py module against itself. Only __init__.pyi and typedefs.pyi carry type information, +# so every other module is excluded from discovery; the overrides below keep mypy from +# following an import into one of them. +exclude = [ + 'polymath/(qube|unit|scalar|boolean|vector|pair|vector3|quaternion|polynomial|matrix|matrix3)\.py$', + 'polymath/extensions/', +] [[tool.mypy.overrides]] module = "polymath._version" ignore_missing_imports = true -# The .pyi stubs alongside each module carry the published type information; the modules -# themselves are deliberately unannotated, so mypy must not be turned loose on them. These -# overrides keep it to the stubs, which is what `mypy.stubtest` needs in order to run. +# The published type information lives in __init__.pyi and typedefs.pyi, because the only +# supported imports are from those two modules. Every other module is deliberately +# unannotated and has no stub, so mypy must not be turned loose on it. These overrides +# keep mypy to the two stubs, which is what `mypy.stubtest` needs in order to run. [[tool.mypy.overrides]] -module = "polymath.extensions.*" +module = [ + "polymath.extensions.*", + "polymath.qube", + "polymath.unit", + "polymath.scalar", + "polymath.boolean", + "polymath.vector", + "polymath.pair", + "polymath.vector3", + "polymath.quaternion", + "polymath.polynomial", + "polymath.matrix", + "polymath.matrix3", +] follow_imports = "skip" ignore_errors = true @@ -127,6 +153,24 @@ ignore_errors = true module = "fpzip" ignore_missing_imports = true +# The tests are the only annotated code mypy checks, and they exercise the runtime API +# far more loosely than a stub can describe. These codes fire on constructs that are +# correct at runtime and that no change to the tests or the stubs can express, so they +# are switched off here rather than silenced line by line thousands of times: +# - Qube declares the arithmetic operators once and returns Qube, so a subclass result +# loses its class: attr-defined, union-attr, assignment, call-arg, operator, misc. +# - values, vals, and mask return a union of a number and an array, so subscripting a +# result that is known to be an array is rejected: index, call-overload. +# - A derivative is attached under a name such as d_dt by setattr, so no declaration +# can exist for it: attr-defined. +# Everything mypy can still say about the tests stays on, including the annotations that +# every test function is required to carry. Each code above is load-bearing: re-enabling +# the set reports over five thousand errors, none of them a defect. +[[tool.mypy.overrides]] +module = "tests.*" +disable_error_code = ["assignment", "attr-defined", "call-arg", "call-overload", + "index", "misc", "operator", "union-attr"] + [tool.ruff] target-version = "py311" # Must match max-line-length in .flake8, which CI currently enforces. diff --git a/scripts/run-all-checks.sh b/scripts/run-all-checks.sh index 4415ffc..34336b4 100755 --- a/scripts/run-all-checks.sh +++ b/scripts/run-all-checks.sh @@ -22,7 +22,7 @@ # --mypy Run mypy only # --pytest Run pytest only # --pyroma Run pyroma only -# --stubtest Run stubtest only (checks the .pyi stubs) +# --stubtest Run stubtest only (checks __init__.pyi and typedefs.pyi) # --bandit Run bandit only # --vulture Run vulture only # --sphinx Run Sphinx build only @@ -48,10 +48,10 @@ # ENABLE_RUFF_CHECK (default: true) # ENABLE_RUFF_FORMAT (default: false) # ENABLE_FLAKE8_CONT continuation-line indent, E12x/E13x (default: true) -# ENABLE_MYPY (default: false) +# ENABLE_MYPY mypy on tests/ only (default: true) # ENABLE_PYTEST (default: true) # ENABLE_PYROMA (default: true) -# ENABLE_STUBTEST .pyi stubs match the runtime API (default: true) +# ENABLE_STUBTEST the two .pyi stubs match the runtime API (default: true) # ENABLE_BANDIT (default: false) # ENABLE_VULTURE (default: false) # ENABLE_SPHINX (default: true) @@ -102,7 +102,7 @@ SCOPE_SPECIFIED=false : "${ENABLE_RUFF_CHECK:=true}" : "${ENABLE_RUFF_FORMAT:=false}" : "${ENABLE_FLAKE8_CONT:=true}" -: "${ENABLE_MYPY:=false}" +: "${ENABLE_MYPY:=true}" : "${ENABLE_PYTEST:=true}" : "${ENABLE_PYROMA:=true}" : "${ENABLE_STUBTEST:=true}" @@ -418,7 +418,7 @@ run_code_checks() { fi if [ "$RUN_MYPY" = true ] && [ "$ENABLE_MYPY" = true ]; then - print_info "Running mypy..." + print_info "Running mypy (tests/ only; src/ is deliberately unannotated)..." if MYPYPATH=src python -m mypy tests; then print_success "Mypy passed" else @@ -454,8 +454,8 @@ run_code_checks() { fi if [ "$RUN_STUBTEST" = true ] && [ "$ENABLE_STUBTEST" = true ]; then - print_info "Running stubtest (.pyi stubs vs the runtime API)..." - if python -m mypy.stubtest polymath --mypy-config-file pyproject.toml; then + print_info "Running stubtest (__init__.pyi and typedefs.pyi vs the runtime API)..." + if python -m mypy.stubtest polymath --mypy-config-file pyproject.toml --allowlist .stubtest-allowlist; then print_success "Stubtest passed" else print_error "Stubtest failed" diff --git a/src/polymath/__init__.py b/src/polymath/__init__.py index e33fe30..11926dc 100755 --- a/src/polymath/__init__.py +++ b/src/polymath/__init__.py @@ -9,17 +9,21 @@ PDS Ring-Moon Systems Node, SETI Institute PolyMath expands on the NumPy module and introduces a variety of additional data types -and features to simplify 3-D geometry calculations. It is a product of the the [PDS +and features to simplify 3-D geometry calculations. It is a product of the [PDS Ring-Moon Systems Node](https://pds-rings.seti.org). The PolyMath classes are: * :class:`Scalar`: A single zero-dimensional number. * :class:`Vector`: An arbitrary 1-D object. -* :class:`Pair`: A subclass of `Vector` representing a vector with two coordinates. -* :class:`Vector3`: A subclass of `Vector` representing a vector with three coordinates. +* :class:`Pair`: A subclass of :class:`Vector` representing a vector with two + coordinates. +* :class:`Vector3`: A subclass of :class:`Vector` representing a vector with three + coordinates. * :class:`Matrix`: An arbitrary 2-D matrix. -* :class:`Matrix3`: A subclass of `Matrix` representing a unitary 3x3 rotation matrix. -* :class:`Quaternion`: A subclass of `Vector` representing a 4-component quaternion. -* :class:`Polynomial`: A subclass of `Vector` representing the coefficients of a +* :class:`Matrix3`: A subclass of :class:`Matrix` representing a unitary 3x3 rotation + matrix. +* :class:`Quaternion`: A subclass of :class:`Vector` representing a 4-component + quaternion. +* :class:`Polynomial`: A subclass of :class:`Vector` representing the coefficients of a polynomial in one variable, in order of decreasing exponent. * :class:`Boolean`: A True or False value. * :class:`Qube`: The superclass of all of the above, supporting objects of arbitrary @@ -55,15 +59,16 @@ added capabilities. For example, if an object is mostly masked, you can use the :meth:`~Qube.shrink` method to speed up math operations by excluding all the masked elements. -* **Units**: Objects can have arbitrary units, as defined by PolyMath's `Unit` class. +* **Units**: Objects can have arbitrary units, as defined by PolyMath's :class:`Unit` + class. * **Read-only** status: It is easy to define an object to be read-only, which will then prevent it from being modified further. This can be useful for preventing NumPy errors that can arise when multiple objects share memory (a common situation) and one of them gets modified by accident. * **Indexing**: An object can be indexed in a variety of ways that expand upon NumPy's indexing rules. -* **Pickling**: Python's `pickle` module can be used to save and re-load objects in a way - that makes for extremely efficient storage. +* **Pickling**: Python's :mod:`pickle` module can be used to save and re-load objects in a + way that makes for extremely efficient storage. PolyMath provides the mathematical underpinnings of the OOPS Library. As an illustration of its power, here are some examples of how OOPS uses PolyMath objects to describe a data @@ -111,7 +116,7 @@ ************************ All standard mathematical operators and indexing/slicing options are defined for PolyMath -objects, where appropriate: `+`, `-`, `*`, `/`, `%`, `//`,`**`, along with their in-place +objects, where appropriate: `+`, `-`, `*`, `/`, `%`, `//`, `**`, along with their in-place variants. Equality tests `==`, `!=` are available for all objects; comparison operators `<`, `<=`, `>`, `>=` are supported for Scalars and Booleans. Where appropriate, methods such as :meth:`~Qube.abs`, :meth:`~Qube.len`, :meth:`~Qube.mean`, :meth:`~Qube.sum`, @@ -232,14 +237,14 @@ Methods :meth:`~Qube.insert_deriv`, :meth:`~Qube.insert_derivs`, :meth:`~Qube.delete_deriv`, :meth:`~Qube.delete_derivs`, and :meth:`~Qube.rename_deriv` -can be used to add, remove, or modify derivatives after it has been constructed. You can -also obtain a shallow copy of an object with one or more derivatives removed using +can be used to add, remove, or modify derivatives after an object has been constructed. +You can also obtain a shallow copy of an object with one or more derivatives removed using :meth:`~Qube.without_deriv` and :meth:`~Qube.without_derivs`. For convenience, the :attr:`~Qube.wod` property is equivalent to :meth:`~Qube.without_derivs`. Note that the presence of derivatives inside an object can slow computational performance significantly, -so it can useful to suppress derivatives from a calculation if they are not needed. Note -that many math functions have a `recursive` option that defaults to True; set it to False -to ignore derivatives within the given calculation. +so it can be useful to suppress derivatives from a calculation if they are not needed. +Note that many math functions have a `recursive` option that defaults to True; set it to +False to ignore derivatives within the given calculation. A number of methods are focused on modifying the numerator and denominator components of objects: :meth:`~Qube.extract_numer`, :meth:`~Qube.extract_denom`, @@ -299,7 +304,7 @@ because the result would be False regardless of the second value. * :meth:`~Qube.tvl_or` returns True if one value is True but the other is masked, because the result would be True regardless of the second value. -* :meth:`~Qube.tvl_all` returns True only if and only all values are True; if any value is +* :meth:`~Qube.tvl_all` returns True if and only if all values are True; if any value is False, it returns False; if the only values are True or indeterminate, its value is indeterminate (meaning masked). * :meth:`~Qube.tvl_any` returns True if any value is True; it returns False if every value @@ -320,16 +325,16 @@ Units **************** -PolyMath objects also support embedded unit using the :class:`Unit` class. However, the +PolyMath objects also support embedded units using the :class:`Unit` class. However, the internal values in a PolyMath object are always held in standard units of kilometers, seconds and radians, or arbitrary combinations thereof. The unit is primarily used to affect the appearance of numbers during input and output. The :attr:`~Qube.unit_` or -:attr:`~Qube.units` property of any object will reveal the class:`Unit` object, or +:attr:`~Qube.units` property of any object will reveal the :class:`Unit` object, or possibly None if the object is unitless. A :class:`Unit` allows for exact conversions between units. It is described by three integer exponents applying to dimensions of length, time, and angle. Conversion factors -are describe by three (usually) integer values representing a numerator, denominator, and +are described by three (usually) integer values representing a numerator, denominator, and an exponent on pi. For example, :attr:`Unit.DEGREE` is represented by exponents (0,0,1) and factors (1,180,1), indicating that the conversion factor is `pi/180`. Most other common units are described by class constants; see the :class:`Unit` class for details. @@ -384,7 +389,7 @@ classes, such as :meth:`Vector.from_scalars`, :meth:`Vector.to_scalar`, :meth:`Vector.to_scalars`, :meth:`Matrix3.twovec` (a rotation matrix defined by two vectors), :meth:`Matrix.row_vector`, :meth:`Matrix.row_vectors`, -:meth:`Matrix.column_vector`, Matrix.column_vectors`, and Matrix.to_vector`. +:meth:`Matrix.column_vector`, :meth:`Matrix.column_vectors`, and :meth:`Matrix.to_vector`. ****************** Indexing @@ -414,7 +419,7 @@ where the :class:`Scalar` index are masked are not changed. * A :class:`Pair` object composed of integers can be used as an index. Each `(i,j)` value - is treated is the index of two consecutive axes, and the associated value is returned. + is treated as the index of two consecutive axes, and the associated value is returned. Where the :class:`Pair` is masked, a masked value is returned. Similarly, a :class:`Vector` with three or more integer elements is treated as the index of three or more consecutive axes. @@ -425,12 +430,12 @@ `(3,1,7,8,9)`; `A[:,B]` has shape `(6,3,1,8,9)`, and `A[...,B]` has shape `(6,7,8,3,1)`. * When multiple arrays are used for indexing at the same time, the broadcasted shape of - these array appears at the location of the first array-valued index. In the same example - as above, suppose `C` has shape `(4,)`. Then `A[B,C]` has shape `(3,4,8,9)`, `A[:,B,C]` - has shape `(6,3,4,9)`, and `A[:,B,:,C]` has shape `(6,3,4,8)`. Note that this behavior - is slightly different from how NumPy handles indexing with multiple arrays. + these arrays appears at the location of the first array-valued index. In the same + example as above, suppose `C` has shape `(4,)`. Then `A[B,C]` has shape `(3,4,8,9)`, + `A[:,B,C]` has shape `(6,3,4,9)`, and `A[:,B,:,C]` has shape `(6,3,4,8)`. Note that this + behavior is slightly different from how NumPy handles indexing with multiple arrays. -Several methods can be used to convert PolyMath objects to objects than be used for +Several methods can be used to convert PolyMath objects to objects that can be used for indexing NumPy arrays. You can obtain integer indices from :meth:`Scalar.as_index`, :meth:`Vector.as_index`, :meth:`Scalar.as_index_and_mask`, and :meth:`Vector.as_index_and_mask`. You can obtain boolean masks from @@ -442,7 +447,7 @@ Iterators ****************** -Every Polymath object can be used as an iterator, in which case it performs an iteration +Every PolyMath object can be used as an iterator, in which case it performs an iteration over the object's leading axis. Alternatively, :meth:`~Qube.ndenumerate` iterates over every item in a multidimensional object. @@ -476,14 +481,14 @@ using :meth:`~Qube.set_default_pickle_digits`. The inputs to these functions are as follows: -**digits** (`str, int, or float`): The number of digits to preserve. +**digits** (`str | int | float`): The number of digits to preserve. * "double": preserve full precision using lossless **fpzip** compression. * "single": convert the array to single precision and then store it using lossless **fpzip** compression. -* an number 7-16, defining the number of significant digits to preserve. +* a number 7-16, defining the number of significant digits to preserve. -**reference** (`str or float`): How to interpret a numeric value of **digits**. +**reference** (`str | float`): How to interpret a numeric value of **digits**. * "fpzip": Use lossy **fpzip** compression, preserving the given number of digits. * a number: Preserve every number to the exact same absolute precision, scaling the number @@ -502,7 +507,7 @@ furthest from zero. This option is useful for arrays that contain a limited range of values, such as the components of a unit vector or angles that are known to fall between zero and `2*pi`. In this case, it is probably not necessary to preserve the extra - precision in values that just happen to fall very close zero. + precision in values that just happen to fall very close to zero. * "mean": Absolute accuracy will be `10**(-digits)` times the mean of the absolute values in the array. * "median": Absolute accuracy will be `10**(-digits)` times the median of the absolute @@ -513,8 +518,8 @@ values in the array. """ -from polymath.qube import Qube -from polymath.unit import Unit +from polymath.qube import Qube +from polymath.unit import Unit # The extension methods must be bound onto Qube before any subclass module is imported. # Each subclass builds read-only class constants, such as Scalar.ZERO, while it loads, and diff --git a/src/polymath/__init__.pyi b/src/polymath/__init__.pyi index 7ca01fa..a81d63d 100644 --- a/src/polymath/__init__.pyi +++ b/src/polymath/__init__.pyi @@ -1,31 +1,927 @@ ########################################################################################## # polymath/__init__.pyi ########################################################################################## -"""Type stub for the PolyMath package namespace. +"""Type stub for the PolyMath package. -The `src` tree carries no inline annotations, so type information for public -symbols is published through stub files instead. Each class is described -by the stub alongside its own module. +The `src` tree carries no inline annotations, so type information for the public symbols +is published here instead. Every public class is described in this one file, because the +only supported imports are ``from polymath import ...`` and +``from polymath.typedefs import ...``; the submodules that define the classes are an +implementation detail and carry no stubs of their own. The stub describes the shape of +the API exactly: every public name, its parameters, which of them are keyword-only, and +which have defaults. Types are taken from the docstrings wherever those state one +unambiguously, and are left as `Any` where they do not, rather than guessed at. -Each import uses the redundant `X as X` form, which is how a stub marks a name -as re-exported rather than merely imported for internal use. +Class constants such as `Scalar.ZERO` are declared as attributes of their class. The +methods of `Qube` are bound onto the class from the extension modules when the package +is imported, and every one of them appears here as though it were defined in the class +body. """ -from polymath.boolean import Boolean as Boolean -from polymath.matrix import Matrix as Matrix -from polymath.matrix3 import Matrix3 as Matrix3 -from polymath.pair import Pair as Pair -from polymath.polynomial import Polynomial as Polynomial -from polymath.quaternion import Quaternion as Quaternion -from polymath.qube import Qube as Qube -from polymath.scalar import Scalar as Scalar -from polymath.unit import Unit as Unit -from polymath.vector import Vector as Vector -from polymath.vector3 import Vector3 as Vector3 +import builtins +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, ClassVar, NoReturn + +import numpy as np + +from polymath.typedefs import (BooleanLike, MaskType, Matrix3Like, MatrixLike, PairLike, + QuaternionLike, QubeLike, ScalarLike, ValsType, + Vector3Like, VectorLike) __version__: str __all__ = ['Boolean', 'Matrix', 'Matrix3', 'Pair', 'Polynomial', 'Quaternion', 'Qube', 'Scalar', 'Unit', 'Vector', 'Vector3'] +class Unit: + ARCHOUR: Unit + ARCHOURS: Unit + ARCMIN: Unit + ARCMINUTE: Unit + ARCMINUTES: Unit + ARCSEC: Unit + ARCSECOND: Unit + ARCSECONDS: Unit + CENTIMETER: Unit + CENTIMETERS: Unit + CM: Unit + CYCLE: Unit + CYCLES: Unit + D: Unit + DAY: Unit + DAYS: Unit + DEG: Unit + DEGREE: Unit + DEGREES: Unit + H: Unit + HOUR: Unit + HOURS: Unit + KILOMETER: Unit + KILOMETERS: Unit + KM: Unit + M: Unit + METER: Unit + METERS: Unit + MICRON: Unit + MICRONS: Unit + MILLIMETER: Unit + MILLIMETERS: Unit + MILLIRAD: Unit + MIN: Unit + MINUTE: Unit + MINUTES: Unit + MM: Unit + MRAD: Unit + MS: Unit + MSEC: Unit + RAD: Unit + RADIAN: Unit + RADIANS: Unit + REV: Unit + REVS: Unit + ROTATION: Unit + ROTATIONS: Unit + S: Unit + SEC: Unit + SECOND: Unit + SECONDS: Unit + STER: Unit + UNITLESS: Unit + def __copy__(self) -> Unit: ... + def __div__(self, arg: Unit | float | int | None) -> Unit: ... + def __eq__(self, arg: object) -> bool: ... + def __init__(self, exponents: tuple[int, int, int], triple: tuple[int, int, int], + name: str | dict | None = ...) -> None: ... # type: ignore[type-arg] + def __mul__(self, arg: Unit | float | int | None) -> Unit: ... + def __ne__(self, arg: object) -> bool: ... + def __pow__(self, power: int | float) -> Unit: ... + def __rdiv__(self, arg: float | int | None) -> Unit: ... + def __repr__(self) -> str: ... + def __rmul__(self, arg: Unit | float | int | None) -> Unit: ... + def __rtruediv__(self, arg: float | int | None) -> Unit: ... + def __str__(self) -> str: ... + def __truediv__(self, arg: Unit | float | int | None) -> Unit: ... + @staticmethod + def as_unit(arg: Unit | str | None) -> Unit | None: ... + @staticmethod + def can_match(first: Unit | None, second: Unit | None) -> bool: ... + def convert(self, value: Any, unit: Unit | None, info: str = ...) -> Any: ... + def copy(self) -> Unit: ... + def create_name(self) -> str | dict: ... # type: ignore[type-arg] + @staticmethod + def div_units(arg1: Unit | None, arg2: Unit | None) -> Unit | None: ... + @staticmethod + def do_match(first: Unit | None, second: Unit | None) -> bool: ... + def from_this(self, value: Any) -> Any: ... + @staticmethod + def from_unit(unit: Unit | None, value: Any) -> Any: ... + @property + def from_unit_factor(self) -> float: ... + def get_name(self) -> str | dict | None: ... # type: ignore[type-arg] + def into_this(self, value: Any) -> Any: ... + @staticmethod + def into_unit(unit: Unit | None, value: Any) -> Any: ... + @property + def into_unit_factor(self) -> float: ... + @staticmethod + def is_angle(arg: Unit | None) -> bool: ... + @staticmethod + def is_unitless(arg: Unit | None) -> bool: ... + @staticmethod + def mul_units(arg1: Unit | None, arg2: Unit | None) -> Unit | None: ... + @staticmethod + def name_to_dict(expr: str | dict) -> dict: ... # type: ignore[type-arg] + @staticmethod + def name_to_str(namedict: str | dict) -> str: ... # type: ignore[type-arg] + @staticmethod + def require_angle(arg: Unit | None, info: str = ...) -> None: ... + @staticmethod + def require_compatible(first: Unit | None, second: Unit | None, info: str = ... + ) -> None: ... + @staticmethod + def require_match(first: Unit | None, second: Unit | None, info: str = ... + ) -> None: ... + @staticmethod + def require_unitless(arg: Unit | None, info: str = ...) -> None: ... + def set_name(self, name: str | dict) -> Unit: ... # type: ignore[type-arg] + def sqrt(self) -> Unit: ... + @staticmethod + def sqrt_unit(unit: Unit | None) -> Unit | None: ... + @staticmethod + def unit_power(unit: Unit | None, power: int | float) -> Unit | None: ... + +class Qube: + # Lets NumPy defer to these operators rather than its own + __array_priority__: ClassVar[builtins.int] + + # Qube compares by value and is mutable, so it is not hashable + __hash__: ClassVar[None] # type: ignore[assignment] + def __abs__(self, *, recursive: bool = ...) -> Qube: ... + def __add__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __and__(self, arg: BooleanLike) -> Boolean: ... + def __bool__(self) -> bool: ... + def __copy__(self) -> Qube: ... + def __eq__(self, arg: object) -> Boolean | bool: ... # type: ignore[override] + def __float__(self) -> float: ... + def __floordiv__(self, arg: QubeLike) -> Qube: ... + def __ge__(self, arg: QubeLike) -> Boolean: ... + def __getitem__(self, indx: Any) -> Qube: ... + def __getstate__(self) -> dict[str, Any]: ... + def __gt__(self, arg: QubeLike) -> Boolean: ... + def __iadd__(self, arg: QubeLike) -> Qube: ... # type: ignore[misc] + def __iand__(self, arg: BooleanLike) -> Qube: ... + def __ifloordiv__(self, arg: QubeLike) -> Qube: ... + def __imod__(self, arg: QubeLike) -> Qube: ... # type: ignore[misc] + def __imul__(self, arg: QubeLike) -> Qube: ... # type: ignore[misc] + def __init__(self, arg: QubeLike, mask: BooleanLike | None = ..., *, + derivs: Mapping[str, Qube] | None = ..., unit: Unit | bool | None = ..., + nrank: int | None = ..., drank: int | None = ..., example: Qube | None = ..., + default: QubeLike | None = ..., op: str = ...) -> None: ... + def __int__(self) -> int: ... + def __invert__(self) -> Boolean: ... + def __ior__(self, arg: BooleanLike) -> Qube: ... + def __ipow__(self, arg: QubeLike) -> Qube: ... + def __isub__(self, arg: QubeLike) -> Qube: ... # type: ignore[misc] + def __iter__(self) -> Iterator[Qube]: ... + def __itruediv__(self, arg: QubeLike) -> Qube: ... # type: ignore[misc] + def __ixor__(self, arg: BooleanLike) -> Qube: ... + def __le__(self, arg: QubeLike) -> Boolean: ... + def __len__(self) -> int: ... + def __lt__(self, arg: QubeLike) -> Boolean: ... + def __matmul__(self, arg: Qube) -> Qube: ... + def __mod__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __mul__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __ne__(self, arg: object) -> Boolean | bool: ... # type: ignore[override] + def __neg__(self, *, recursive: bool = ...) -> Qube: ... + @staticmethod + def __new__(subtype: type, *values: Any, **keywords: Any) -> Qube: ... + def __or__(self, arg: BooleanLike) -> Boolean: ... + def __pos__(self, *, recursive: bool = ...) -> Qube: ... + def __pow__(self, arg: QubeLike) -> Qube: ... + def __radd__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __rand__(self, arg: BooleanLike) -> Boolean: ... + def __repr__(self) -> str: ... + def __rfloordiv__(self, arg: QubeLike) -> Qube: ... + def __rmod__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __rmul__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __ror__(self, arg: BooleanLike) -> Boolean: ... + def __rsub__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __rtruediv__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __rxor__(self, arg: BooleanLike) -> Boolean: ... + def __setitem__(self, indx: Any, arg: QubeLike) -> None: ... + def __setstate__(self, state: dict[str, Any]) -> None: ... + def __str__(self) -> str: ... + def __sub__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __truediv__(self, arg: QubeLike, *, recursive: bool = ...) -> Qube: ... + def __xor__(self, arg: BooleanLike) -> Boolean: ... + def abs(self) -> Qube: ... + def add_attr(self, name: str, value: Any = ...) -> Qube: ... + def all(self, axis: int | Sequence[int] | None = ..., *, + builtins: bool | None = ..., masked: bool | None = ..., out: Any = ... + ) -> Boolean | bool: ... + def all_true_or_masked(self, axis: int | Sequence[int] | None = ..., *, + builtins: bool | None = ...) -> Boolean | bool: ... + @staticmethod + def and_(*masks: MaskType) -> MaskType: ... + @property + def antimask(self) -> MaskType: ... + def any(self, axis: int | Sequence[int] | None = ..., *, + builtins: bool | None = ..., masked: bool | None = ..., out: Any = ... + ) -> Boolean | bool: ... + def any_true_or_masked(self, axis: int | Sequence[int] | None = ..., *, + builtins: bool | None = ...) -> Boolean | bool: ... + def as_all_constant(self, constant: QubeLike | None = ..., *, recursive: bool = ... + ) -> Qube: ... + def as_all_masked(self, *, recursive: bool = ...) -> Qube: ... + def as_bool(self, *, copy: bool = ..., builtins: bool = ...) -> Qube | bool: ... + def as_builtin(self, masked: float | int | bool | None = ... + ) -> Qube | float | int | bool | None: ... + @staticmethod + def as_diagonal(arg: Qube, axis: int, *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + def as_float(self, *, recursive: bool = ..., copy: bool = ..., builtins: bool = ... + ) -> Qube: ... + def as_int(self, *, copy: bool = ..., builtins: bool = ...) -> Qube | int: ... + def as_mask_where_nonzero(self) -> np.ndarray | bool: ... + def as_mask_where_nonzero_or_masked(self) -> np.ndarray | bool: ... + def as_mask_where_zero(self) -> np.ndarray | bool: ... + def as_mask_where_zero_or_masked(self) -> np.ndarray | bool: ... + def as_numeric(self, *, recursive: bool = ...) -> Qube: ... + @staticmethod + def as_one_bool(value: Any) -> Any: ... + def as_one_masked(self, *, recursive: bool = ...) -> Qube: ... + def as_readonly(self, *, recursive: bool = ...) -> Qube: ... + def as_size_zero(self, axis: int = ..., *, recursive: bool = ...) -> Qube: ... + def as_this_type(self, arg: QubeLike, *, recursive: bool = ..., coerce: bool = ..., + op: str = ...) -> Qube: ... + @staticmethod + def broadcast(*objects: QubeLike | None, recursive: bool = ..., + _protected: bool = ...) -> tuple[Any, ...]: ... + def broadcast_into_shape(self, shape: tuple[int, ...], *, recursive: bool = ..., + _protected: bool = ...) -> Qube: ... + def broadcast_to(self, shape: tuple[int, ...], *, recursive: bool = ..., + _protected: bool = ...) -> Qube: ... + @staticmethod + def broadcasted_shape(*objects: QubeLike | None, + item: tuple[int, ...] | list[int] = ...) -> tuple[int, ...]: ... + def cast(self, *, classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + def chain(self, arg: Qube) -> Qube: ... + def clip(self, lower: Any, upper: Any, *, remask: bool = ..., inclusive: bool = ... + ) -> Qube: ... + def clone(self, *, recursive: bool = ..., + preserve: str | list[str] | tuple[str, ...] | set[str] = ..., + retain_cache: bool = ...) -> Qube: ... + def collapse_mask(self, *, recursive: bool = ...) -> Qube: ... + def confirm_unit(self, unit: Unit | None) -> Qube: ... + def copy(self, *, recursive: bool = ..., readonly: bool = ...) -> Qube: ... + @property + def corners(self) -> tuple[tuple[int, ...], tuple[int, ...]] | None: ... + def count_masked(self) -> int: ... + def count_unmasked(self) -> int: ... + @staticmethod + def cross(arg1: Qube, arg2: Qube, axis1: int = ..., axis2: int = ..., *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + @property + def default(self) -> np.ndarray | float | int | bool: ... + def delete_deriv(self, key: str, *, override: bool = ...) -> None: ... + def delete_derivs(self, *, override: bool = ..., + preserve: str | list[str] | tuple[str, ...] | set[str] | None = ...) -> None: ... + @property + def denom(self) -> tuple[int, ...]: ... + @property + def derivs(self) -> dict[str, Qube]: ... + @staticmethod + def dot(arg1: Qube, arg2: Qube, axis1: int = ..., axis2: int = ..., *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + @property + def drank(self) -> int: ... + @property + def dsize(self) -> int: ... + def dtype(self) -> str: ... + def expand_mask(self, *, recursive: bool = ...) -> Qube: ... + def extract_denom(self, axis: int, index: int, *, + classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + def extract_denoms(self) -> list[Qube]: ... + def extract_numer(self, axis: int, index: int, *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + @classmethod + def filled(cls, shape: tuple[int, ...], fill: QubeLike = ..., *, + numer: tuple[int, ...] | None = ..., denom: tuple[int, ...] = ..., + mask: BooleanLike = ...) -> Qube: ... + def flatten(self, *, recursive: bool = ...) -> Qube: ... + def flatten_denom(self) -> Qube: ... + def flatten_numer(self, *, classes: type | list[type] | tuple[type, ...] = ..., + recursive: bool = ...) -> Qube: ... + @classmethod + def from_scalars(cls, *scalars: QubeLike, recursive: bool = ..., + readonly: bool = ..., classes: type | list[type] | tuple[type, ...] = ... + ) -> Qube: ... + def identity(self) -> Qube: ... + def insert_deriv(self, key: str, deriv: Qube, *, override: bool = ...) -> Qube: ... + def insert_derivs(self, derivs: Mapping[str, Qube], *, + override: bool = ...) -> Qube: ... + def into_unit(self, *, recursive: bool = ... + ) -> np.ndarray | float | int | bool | tuple: ... # type: ignore[type-arg] + @staticmethod + def is_above(arg: Any, high: Any, *, inclusive: bool = ...) -> MaskType: ... + def is_all_masked(self) -> bool: ... + @staticmethod + def is_below(arg: Any, high: Any, *, inclusive: bool = ...) -> MaskType: ... + def is_bool(self) -> bool: ... + def is_float(self) -> bool: ... + @staticmethod + def is_inside(arg: Any, low: Any, high: Any, *, inclusive: bool = ... + ) -> MaskType: ... + def is_int(self) -> bool: ... + def is_numeric(self) -> bool: ... + @staticmethod + def is_one_false(value: Any) -> bool: ... + @staticmethod + def is_one_true(value: Any) -> bool: ... + @staticmethod + def is_outside(arg: Any, low: Any, high: Any, *, inclusive: bool = ... + ) -> MaskType: ... + def is_unitless(self) -> bool: ... + @property + def isize(self) -> int: ... + @property + def item(self) -> tuple[int, ...]: ... + def join_items(self, *, classes: type | list[type] | tuple[type, ...] = ... + ) -> Qube: ... + def len(self) -> int: ... + def logical_not(self) -> Boolean: ... + @property + def mask(self) -> MaskType: ... + def mask_where(self, mask: BooleanLike, replace: Any = ..., *, + remask: bool = ..., recursive: bool = ...) -> Qube: ... + def mask_where_between(self, lower: QubeLike, upper: QubeLike, *, + mask_endpoints: bool | Sequence[bool] = ..., replace: QubeLike | None = ..., + remask: bool = ...) -> Qube: ... + def mask_where_eq(self, match: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_ge(self, limit: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_gt(self, limit: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_le(self, limit: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_lt(self, limit: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_ne(self, match: Any, replace: Any = ..., *, remask: bool = ... + ) -> Qube: ... + def mask_where_outside(self, lower: QubeLike, upper: QubeLike, *, + mask_endpoints: bool | Sequence[bool] = ..., replace: QubeLike | None = ..., + remask: bool = ...) -> Qube: ... + def masked_single(self, *, recursive: bool = ...) -> Qube: ... + def match_readonly(self, arg: Qube) -> Qube: ... + def mean(self, axis: int | Sequence[int] | None = ..., *, recursive: bool = ..., + builtins: bool | None = ..., masked: float | int | None = ..., + dtype: Any = ..., out: Any = ...) -> Qube | float | int: ... + def move_axis(self, source: int | tuple[int, ...], + destination: int | tuple[int, ...], *, recursive: bool = ..., + rank: int | None = ...) -> Qube: ... + @property + def mvals(self) -> np.ma.MaskedArray: ... + def ndenumerate(self) -> Iterator[tuple[tuple[int, ...], Qube]]: ... + @property + def ndim(self) -> int: ... + @property + def ndims(self) -> int: ... + @staticmethod + def norm(arg: Qube, axis: int = ..., *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + @staticmethod + def norm_sq(arg: Qube, axis: int = ..., *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + @property + def nrank(self) -> int: ... + @property + def nsize(self) -> int: ... + @property + def numer(self) -> tuple[int, ...]: ... + @classmethod + def ones(cls, shape: tuple[int, ...], dtype: str = ..., *, + numer: tuple[int, ...] | None = ..., denom: tuple[int, ...] = ..., + mask: BooleanLike = ...) -> Qube: ... + @staticmethod + def or_(*masks: MaskType) -> MaskType: ... + @staticmethod + def outer(arg1: Qube, arg2: Qube, *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + def pickle_digits(self) -> tuple[str | float | int, + str | float | int]: ... + def pickle_reference(self) -> tuple[str | float | int, + str | float | int]: ... + @staticmethod + def prefer_builtins(status: bool | None = ...) -> bool: ... + @property + def rank(self) -> int: ... + @property + def readonly(self) -> bool: ... + def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ...) -> Qube: ... + def remask(self, mask: BooleanLike, *, recursive: bool = ..., check: bool = ... + ) -> Qube: ... + def remask_or(self, mask: BooleanLike, *, recursive: bool = ..., check: bool = ... + ) -> Qube: ... + def rename_deriv(self, key: str, new_key: str, *, method: str = ...) -> Qube: ... + def require_writable(self, force: bool = ...) -> Qube: ... + def require_writeable(self, force: bool = ...) -> Qube: ... + def reshape(self, shape: tuple[int, ...] | int, *, recursive: bool = ...) -> Qube: ... + def reshape_denom(self, shape: tuple[int, ...]) -> Qube: ... + def reshape_numer(self, shape: tuple[int, ...], *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + def rms(self) -> Scalar: ... + def roll_axis(self, axis: int, start: int = ..., *, recursive: bool = ..., + rank: int | None = ...) -> Qube: ... + @staticmethod + def set_default_pickle_digits(digits: int | float | str | Sequence[Any] | None = ..., + reference: int | float | str | Sequence[Any] | None = ...) -> None: ... + def set_pickle_digits(self, digits: int | float | str | Sequence[Any] | None = ..., + reference: int | float | str | Sequence[Any] | None = ...) -> None: ... + def set_unit(self, unit: Unit | str | None, *, override: bool = ...) -> None: ... + @property + def shape(self) -> tuple[int, ...]: ... + def shrink(self, antimask: BooleanLike) -> Qube: ... + @property + def size(self) -> int: ... + def slice_numer(self, axis: int, index1: int, index2: int, *, + classes: type | list[type] | tuple[type, ...] = ..., recursive: bool = ... + ) -> Qube: ... + def split_items(self, nrank: int, *, + classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + @staticmethod + def stack(*args: QubeLike | None, recursive: bool = ...) -> Qube: ... + def sum(self, axis: int | Sequence[int] | None = ..., *, recursive: bool = ..., + builtins: bool | None = ..., masked: float | int | None = ..., + out: Any = ...) -> Qube | float | int: ... + def swap_axes(self, axis1: int, axis2: int, *, recursive: bool = ...) -> Qube: ... + def swap_items(self, *, classes: type | list[type] | tuple[type, ...] = ... + ) -> Qube: ... + def transpose_denom(self, axis1: int = ..., axis2: int = ...) -> Qube: ... + def transpose_numer(self, axis1: int = ..., axis2: int = ..., *, + recursive: bool = ...) -> Qube: ... + def tvl_all(self, axis: int | Sequence[int] | None = ..., + builtins: bool | None = ..., masked: bool | None = ...) -> Boolean | bool: ... + def tvl_and(self, arg: BooleanLike, builtins: bool | None = ..., + masked: bool | None = ...) -> Boolean | bool: ... + def tvl_any(self, axis: int | Sequence[int] | None = ..., + builtins: bool | None = ..., masked: bool | None = ...) -> Boolean | bool: ... + def tvl_eq(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_ge(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_gt(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_le(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_lt(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_ne(self, arg: QubeLike, builtins: bool | None = ...) -> Boolean | bool: ... + def tvl_or(self, arg: BooleanLike, builtins: bool | None = ..., + masked: bool | None = ...) -> Boolean | bool: ... + def unique_deriv_name(self, key: str, *objects: Qube) -> str: ... + @property + def unit_(self) -> Unit | None: ... + @property + def units(self) -> Unit | None: ... + def unshrink(self, antimask: BooleanLike, shape: tuple[int, ...] = ...) -> Qube: ... + @property + def vals(self) -> ValsType: ... + @property + def values(self) -> ValsType: ... + def with_deriv(self, key: str, value: Qube, *, method: str = ...) -> Qube: ... + def without_deriv(self, key: str) -> Qube: ... + def without_derivs(self, *, + preserve: str | list[str] | tuple[str, ...] | set[str] | None = ...) -> Qube: ... + def without_mask(self, *, recursive: bool = ...) -> Qube: ... + def without_unit(self, *, recursive: bool = ...) -> Qube: ... + @property + def wod(self) -> Qube: ... + def zero(self) -> Qube: ... + @classmethod + def zeros(cls, shape: tuple[int, ...], dtype: str = ..., *, + numer: tuple[int, ...] | None = ..., denom: tuple[int, ...] = ..., + mask: BooleanLike = ...) -> Qube: ... + +class Scalar(Qube): + HALFPI: Scalar + INF: Scalar + MASKED: Scalar + NEGINF: Scalar + ONE: Scalar + PI: Scalar + THREE: Scalar + TWO: Scalar + TWOPI: Scalar + ZERO: Scalar + def __abs__(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def __ge__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __gt__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __le__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __lt__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __pow__(self, expo: ScalarLike, *, recursive: bool = ...) -> Scalar: ... + def __round__(self, digits: builtins.int) -> Scalar: ... + def abs(self, *, recursive: bool = ...) -> Scalar: ... + def arccos(self, *, recursive: bool = ..., check: bool = ...) -> Scalar: ... + def arcsin(self, *, recursive: bool = ..., check: bool = ...) -> Scalar: ... + def arctan(self, *, recursive: bool = ...) -> Scalar: ... + def arctan2(self, arg: ScalarLike, *, recursive: bool = ...) -> Scalar: ... + def argmax(self, axis: builtins.int | None = ..., *, builtins: bool | None = ..., + masked: builtins.int | None = ...) -> Scalar | builtins.int: ... + def argmin(self, axis: builtins.int | None = ..., *, builtins: bool | None = ..., + masked: builtins.int | None = ...) -> Scalar | builtins.int: ... + def as_index(self, *, masked: builtins.int | None = ... + ) -> np.ndarray | builtins.int: ... + def as_index_and_mask(self, *, purge: bool = ..., masked: builtins.int | None = ... + ) -> tuple[np.ndarray | builtins.int, np.ndarray | builtins.bool]: ... + @staticmethod + def as_scalar(arg: ScalarLike | Unit, *, recursive: bool = ...) -> Scalar: ... + def cos(self, *, recursive: bool = ...) -> Scalar: ... + def eval_quadratic(self, a: ScalarLike, b: ScalarLike, c: ScalarLike, *, + recursive: bool = ...) -> Scalar: ... + def exp(self, *, recursive: bool = ..., check: bool = ...) -> Scalar: ... + def frac(self, *, recursive: bool = ...) -> Scalar: ... + def identity(self) -> Scalar: ... + def int(self, top: builtins.int | None = ..., *, remask: bool = ..., clip: bool = ..., + inclusive: bool = ..., shift: bool | None = ..., builtins: bool | None = ..., + masked: builtins.int | None = ...) -> Scalar | builtins.int: ... + def log(self, *, recursive: bool = ..., check: bool = ...) -> Scalar: ... + def max(self, axis: builtins.int | tuple[builtins.int, ...] | None = ..., *, + builtins: bool | None = ..., masked: float | builtins.int | None = ..., + out: Any = ...) -> Scalar | float | builtins.int: ... + @staticmethod + def maximum(*args: ScalarLike) -> Scalar: ... + def median(self, axis: builtins.int | tuple[builtins.int, ...] | None = ..., *, + builtins: bool | None = ..., masked: float | builtins.int | None = ..., + out: Any = ...) -> Scalar | float | builtins.int: ... + def min(self, axis: builtins.int | tuple[builtins.int, ...] | None = ..., *, + builtins: bool | None = ..., masked: float | builtins.int | None = ..., + out: Any = ...) -> Scalar | float | builtins.int: ... + @staticmethod + def minimum(*args: ScalarLike) -> Scalar: ... + def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ... + ) -> Scalar: ... + def sign(self, *, zeros: bool = ..., builtins: bool | None = ..., + masked: builtins.int | None = ...) -> Scalar | builtins.int: ... + def sin(self, *, recursive: bool = ...) -> Scalar: ... + @staticmethod + def solve_quadratic(a: ScalarLike, b: ScalarLike, c: ScalarLike, *, + recursive: bool = ..., include_antimask: bool = ... + ) -> tuple[Scalar, Scalar] | tuple[Scalar, Scalar, Boolean]: ... + def sort(self, axis: builtins.int = ...) -> Scalar: ... + def sqrt(self, *, recursive: bool = ..., check: bool = ...) -> Scalar: ... + def tan(self, *, recursive: bool = ...) -> Scalar: ... + def to_scalar(self, indx: builtins.int, *, recursive: bool = ...) -> Scalar: ... + +class Boolean(Scalar): + FALSE: Boolean + MASKED: Boolean + TRUE: Boolean + def __abs__(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def __add__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __floordiv__(self, arg: Any) -> Scalar: ... + def __ge__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __gt__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __iadd__(self, arg: Any) -> None: ... # type: ignore[misc, override] + def __ifloordiv__(self, arg: Any) -> None: ... # type: ignore[override] + def __imod__(self, arg: Any) -> None: ... # type: ignore[override] + def __imul__(self, arg: Any) -> None: ... # type: ignore[misc, override] + def __ipow__(self, arg: Any) -> None: ... # type: ignore[override] + def __isub__(self, arg: Any) -> None: ... # type: ignore[misc, override] + def __itruediv__(self, arg: Any) -> None: ... # type: ignore[misc, override] + def __le__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __lt__(self, arg: Any, *, builtins: bool = ... # type: ignore[override] + ) -> Boolean | bool: ... + def __mod__(self, arg: Any) -> Scalar: ... # type: ignore[override] + def __mul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __neg__(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def __pos__(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def __pow__(self, arg: Any) -> Scalar: ... # type: ignore[override] + def __radd__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __rfloordiv__(self, arg: Any) -> Scalar: ... + def __rmod__(self, arg: Any) -> Scalar: ... # type: ignore[override] + def __rmul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __rsub__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __rtruediv__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __sub__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def __truediv__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + @staticmethod + def as_boolean(arg: BooleanLike, *, recursive: bool = ...) -> Boolean: ... + def as_index(self) -> np.ndarray | bool: ... # type: ignore[override] + def identity(self) -> Boolean: ... + def sum(self, axis: int | Sequence[int] | None = ..., *, value: bool = ..., # type: ignore[override] + builtins: bool | None = ..., recursive: bool = ..., + masked: builtins.int | None = ..., out: Any = ... + ) -> Scalar | builtins.int: ... + +class Vector(Qube): + MASKED2: Vector + MASKED3: Vector + XAXIS2: Vector + XAXIS3: Vector + YAXIS2: Vector + YAXIS3: Vector + ZAXIS3: Vector + ZERO2: Vector + ZERO3: Vector + def __abs__(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def __init__(self, arg: VectorLike | float | int, *args: Any, + **kwargs: Any) -> None: ... + def as_column(self, *, recursive: bool = ...) -> Matrix: ... + def as_diagonal(self, *, recursive: bool = ... # type: ignore[override] + ) -> Matrix: ... + def as_index(self, *, masked: ScalarLike | None = ... + ) -> tuple[np.ndarray, ...]: ... + def as_index_and_mask(self, *, purge: bool = ..., masked: ScalarLike | None = ... + ) -> tuple[tuple[np.ndarray | np.integer[Any], ...], MaskType]: ... + def as_row(self, *, recursive: bool = ...) -> Matrix: ... + @staticmethod + def as_vector(arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + def clip_component(self, axis: builtins.int, lower: ScalarLike | None, + upper: ScalarLike | None, *, remask: bool = ...) -> Vector: ... + @classmethod + def combos(cls, *args: ScalarLike) -> Vector: ... + def cross(self, arg: VectorLike, *, recursive: bool = ... # type: ignore[override] + ) -> Vector | Scalar: ... + def cross_product_as_matrix(self, *, recursive: bool = ...) -> Matrix: ... + def dot(self, arg: VectorLike, *, recursive: bool = ... # type: ignore[override] + ) -> Scalar: ... + def element_div(self, arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + def element_mul(self, arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + @staticmethod + def from_scalars(*args: ScalarLike | None, recursive: bool = ..., # type: ignore[override] + readonly: bool = ...) -> Vector: ... + def identity(self) -> NoReturn: ... + def int(self, top: builtins.int | tuple[builtins.int, ...] | None = ..., *, + remask: bool = ..., + clip: bool | tuple[bool, ...] = ..., inclusive: bool | tuple[bool, ...] = ..., + shift: bool | tuple[bool, ...] | None = ...) -> Vector: ... + def mask_where_component_ge(self, axis: builtins.int, limit: ScalarLike, *, + replace: ScalarLike | None = ..., remask: bool = ...) -> Vector: ... + def mask_where_component_gt(self, axis: builtins.int, limit: ScalarLike, *, + replace: ScalarLike | None = ..., remask: bool = ...) -> Vector: ... + def mask_where_component_le(self, axis: builtins.int, limit: ScalarLike, *, + replace: ScalarLike | None = ..., remask: bool = ...) -> Vector: ... + def mask_where_component_lt(self, axis: builtins.int, limit: ScalarLike, *, + replace: ScalarLike | None = ..., remask: bool = ...) -> Vector: ... + def norm(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def norm_sq(self, *, recursive: bool = ...) -> Scalar: ... # type: ignore[override] + def outer(self, arg: VectorLike, *, recursive: bool = ... # type: ignore[override] + ) -> Matrix: ... + def perp(self, arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + def proj(self, arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + def reciprocal(self, *, nozeros: bool = ...) -> Vector: ... # type: ignore[override] + def sep(self, arg: VectorLike, *, recursive: bool = ...) -> Scalar: ... + def to_pair(self, axes: tuple[builtins.int, builtins.int] = ..., *, + recursive: bool = ...) -> Pair: ... + def to_scalar(self, indx: builtins.int, *, recursive: bool = ...) -> Scalar: ... + def to_scalars(self, *, recursive: bool = ...) -> tuple[Scalar, ...]: ... + def ucross(self, arg: VectorLike, *, recursive: bool = ...) -> Vector: ... + def unit(self, *, recursive: bool = ...) -> Vector: ... + def vector_scale(self, factor: Vector, *, recursive: bool = ...) -> Vector: ... + def vector_unscale(self, factor: Vector, *, recursive: bool = ...) -> Vector: ... + def with_norm(self, norm: ScalarLike = ..., *, recursive: bool = ...) -> Vector: ... + +class Pair(Vector): + HALF: Pair + IDENTITY: Pair + INT00: Pair + INT11: Pair + MASKED: Pair + ONES: Pair + XAXIS: Pair + YAXIS: Pair + ZERO: Pair + ZEROS: Pair + def angle(self, *, recursive: bool = ...) -> Scalar: ... + @staticmethod + def as_pair(arg: PairLike, *, recursive: bool = ...) -> Pair: ... + def clip2d(self, lower: PairLike | None, upper: PairLike | None, *, + remask: bool = ...) -> Pair: ... + @staticmethod + def from_scalars(x: ScalarLike | None, y: ScalarLike | None, *, # type: ignore[override] + recursive: bool = ..., readonly: bool = ...) -> Pair: ... + def rot90(self, *, recursive: bool = ...) -> Pair: ... + def swapxy(self, *, recursive: bool = ...) -> Pair: ... + +class Vector3(Vector): + AXES: tuple[Any, ...] + IDENTITY: Vector3 + MASKED: Vector3 + ONES: Vector3 + XAXIS: Vector3 + YAXIS: Vector3 + ZAXIS: Vector3 + ZERO: Vector3 + ZERO_POS_VEL: Vector3 + @staticmethod + def as_vector3(arg: Vector3Like, *, recursive: bool = ...) -> Vector3: ... + @staticmethod + def from_cylindrical(radius: ScalarLike, longitude: ScalarLike, z: ScalarLike = ..., + *, recursive: bool = ...) -> Vector3: ... + @staticmethod + def from_ra_dec_length(ra: ScalarLike, dec: ScalarLike, length: ScalarLike = ..., *, + recursive: bool = ...) -> Vector3: ... + @staticmethod + def from_scalars(x: ScalarLike | None, y: ScalarLike | None, # type: ignore[override] + z: ScalarLike | None, *, recursive: bool = ..., + readonly: bool = ...) -> Vector3: ... + def latitude(self, *, recursive: bool = ...) -> Scalar: ... + def longitude(self, *, recursive: bool = ...) -> Scalar: ... + def offset_angles(self, vector: Vector3Like, *, recursive: bool = ... + ) -> tuple[Scalar, Scalar]: ... + def spin(self, pole: Vector3Like, angle: ScalarLike | None = ..., *, + recursive: bool = ...) -> Vector3: ... + def to_cylindrical(self, *, recursive: bool = ... + ) -> tuple[Scalar, Scalar, Scalar]: ... + def to_ra_dec_length(self, *, recursive: bool = ... + ) -> tuple[Scalar, Scalar, Scalar]: ... + +class Quaternion(Vector): + IDENTITY: Quaternion + MASKED: Quaternion + XAXIS: Quaternion + YAXIS: Quaternion + ZAXIS: Quaternion + ZERO: Quaternion + def __mul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Quaternion: ... + def __rmul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Quaternion: ... + def __truediv__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Quaternion: ... + @staticmethod + def as_quaternion(arg: QuaternionLike, *, recursive: bool = ...) -> Quaternion: ... + def conj(self, *, recursive: bool = ...) -> Quaternion: ... + @staticmethod + def from_euler(ai: ScalarLike, aj: ScalarLike, ak: ScalarLike, + axes: str | tuple[int, int, int, int] = ...) -> Quaternion: ... + @staticmethod + def from_euler_via_matrix(ai: ScalarLike, aj: ScalarLike, ak: ScalarLike, + axes: str | tuple[int, int, int, int] = ...) -> Quaternion: ... + @staticmethod + def from_matrix3(matrix: Matrix3Like, *, recursive: bool = ...) -> Quaternion: ... + @staticmethod + def from_parts(scalar: ScalarLike | None, vector: Vector3Like | None, *, + recursive: bool = ...) -> Quaternion: ... + @staticmethod + def from_rotation(angle: ScalarLike, vector: Vector3Like, *, recursive: bool = ... + ) -> Quaternion: ... + def identity(self) -> Quaternion: ... # type: ignore[override] + def reciprocal(self, *, recursive: bool = ... # type: ignore[override] + ) -> Quaternion: ... + def to_euler(self, axes: str | tuple[int, int, int, int] = ... + ) -> tuple[Scalar, Scalar, Scalar]: ... + def to_matrix3(self, *, recursive: bool = ..., partials: bool = ... + ) -> Matrix3 | tuple[Matrix3, Matrix]: ... + def to_parts(self, *, recursive: bool = ...) -> tuple[Scalar, Vector3]: ... + def to_rotation(self, *, recursive: bool = ...) -> tuple[Scalar, Vector3]: ... + +class Polynomial(Vector): + def __add__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __eq__(self, arg: object) -> Boolean: ... # type: ignore[override] + def __iadd__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __imul__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + def __isub__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __itruediv__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __mul__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __ne__(self, arg: object) -> Boolean: ... # type: ignore[override] + def __neg__(self) -> Polynomial: ... # type: ignore[override] + def __pow__(self, arg: int | float) -> Polynomial: ... # type: ignore[override] + def __radd__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __rmul__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __rsub__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __sub__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + def __truediv__(self, arg: Any) -> Polynomial: ... # type: ignore[override] + @staticmethod + def as_polynomial(arg: VectorLike, *, recursive: bool = ...) -> Polynomial: ... + def as_vector(self, *, recursive: bool = ...) -> Vector: ... # type: ignore[override] + def at_least_order(self, order: int, *, recursive: bool = ...) -> Polynomial: ... + def deriv(self, *, recursive: bool = ...) -> Polynomial: ... + def eval(self, x: ScalarLike, *, recursive: bool = ...) -> Scalar: ... + def invert_line(self, *, recursive: bool = ...) -> Polynomial: ... + @property + def order(self) -> int: ... + def roots(self, *, recursive: bool = ...) -> Scalar: ... + def set_order(self, order: int, *, recursive: bool = ...) -> Polynomial: ... + +class Matrix(Qube): + IDENTITY2: Matrix + IDENTITY3: Matrix + MASKED2: Matrix + MASKED3: Matrix + @property + def T(self) -> Matrix: ... # noqa: N802 + UNIT33: Matrix + XAXIS_COL: Matrix + XAXIS_ROW: Matrix + YAXIS_COL: Matrix + YAXIS_ROW: Matrix + ZAXIS_COL: Matrix + ZAXIS_ROW: Matrix + ZERO33: Matrix + ZERO3_COL: Matrix + ZERO3_ROW: Matrix + def __abs__(self) -> None: ... # type: ignore[override] + def __floordiv__(self, arg: Any) -> NoReturn: ... + def __ifloordiv__(self, arg: Any) -> NoReturn: ... + def __imod__(self, arg: Any) -> NoReturn: ... # type: ignore[override] + def __mod__(self, arg: Any) -> NoReturn: ... # type: ignore[override] + def __rfloordiv__(self, arg: Any) -> NoReturn: ... + def __rmod__(self, arg: Any) -> NoReturn: ... # type: ignore[override] + @staticmethod + def as_matrix(arg: MatrixLike, *, recursive: bool = ...) -> Matrix: ... + def column_vector(self, column: int, *, recursive: bool = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + def column_vectors(self, *, recursive: bool = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> tuple[Qube, ...]: ... + @staticmethod + def from_scalars(*args: Any, recursive: bool = ..., # type: ignore[override] + shape: tuple[int, ...] | None = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> Matrix: ... + def identity(self) -> Matrix: ... + def inverse(self, *, recursive: bool = ..., nozeros: bool = ...) -> Matrix: ... + def is_diagonal(self, *, delta: float = ...) -> Boolean: ... + def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ... + ) -> Matrix: ... + def row_vector(self, row: int, *, recursive: bool = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + def row_vectors(self, *, recursive: bool = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> tuple[Qube, ...]: ... + def solve(self, arg: VectorLike, *, recursive: bool = ..., nozeros: bool = ... + ) -> Vector: ... + def to_scalar(self, indx0: int, indx1: int, *, recursive: bool = ...) -> Scalar: ... + def to_vector(self, axis: int, indx: int, *, recursive: bool = ..., + classes: type | list[type] | tuple[type, ...] = ...) -> Qube: ... + def transpose(self, *, recursive: bool = ...) -> Matrix: ... + def unitary(self) -> Matrix3: ... + +class Matrix3(Matrix): + IDENTITY: Matrix3 + MASKED: Matrix3 + def __add__(self, arg: Any) -> None: ... # type: ignore[override] + def __getstate__(self) -> dict: ... # type: ignore[type-arg] + def __iadd__(self, arg: Any) -> None: ... # type: ignore[override] + def __imul__(self, arg: Any) -> Matrix3: ... # type: ignore[misc, override] + def __isub__(self, arg: Any) -> None: ... # type: ignore[override] + def __mul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Qube: ... + def __neg__(self) -> None: ... # type: ignore[override] + def __radd__(self, arg: Any) -> None: ... # type: ignore[override] + def __rmul__(self, arg: Any, *, recursive: bool = ... # type: ignore[override] + ) -> Qube: ... + def __rsub__(self, arg: Any) -> None: ... # type: ignore[override] + def __setstate__(self, state: dict[str, Any]) -> None: ... + def __sub__(self, arg: Any) -> None: ... # type: ignore[override] + @staticmethod + def as_matrix3(arg: Matrix3Like, *, recursive: bool = ...) -> Matrix3: ... + @staticmethod + def axis_rotation(angle: ScalarLike, axis: int = ..., *, recursive: bool = ... + ) -> Matrix3: ... + @staticmethod + def from_euler(ai: ScalarLike, aj: ScalarLike, ak: ScalarLike, + axes: str | tuple[int, int, int, int] = ...) -> Matrix3: ... + def mean(self, axis: int | Sequence[int] | None = ..., *, recursive: bool = ..., # type: ignore[override] + builtins: bool | None = ..., dtype: Any = ..., out: Any = ... + ) -> None: ... + @staticmethod + def pole_rotation(ra: ScalarLike, dec: ScalarLike) -> Matrix3: ... + def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ... + ) -> Matrix3: ... + def rotate(self, arg: Qube, *, recursive: bool = ...) -> Qube: ... + def sum(self, axis: int | Sequence[int] | None = ..., *, recursive: bool = ..., # type: ignore[override] + builtins: bool | None = ..., out: Any = ...) -> None: ... + def to_euler(self, axes: str | tuple[int, int, int, int] = ... + ) -> tuple[Scalar, Scalar, Scalar]: ... + def to_quaternion(self, *, recursive: bool = ...) -> Quaternion: ... + @staticmethod + def twovec(vector1: Vector3Like, axis1: int, vector2: Vector3Like, axis2: int, *, + recursive: bool = ...) -> Matrix3: ... + def unrotate(self, arg: Qube, *, recursive: bool = ...) -> Qube: ... + @staticmethod + def x_rotation(angle: ScalarLike, *, recursive: bool = ...) -> Matrix3: ... + @staticmethod + def y_rotation(angle: ScalarLike, *, recursive: bool = ...) -> Matrix3: ... + @staticmethod + def z_rotation(angle: ScalarLike, *, recursive: bool = ...) -> Matrix3: ... + ########################################################################################## diff --git a/src/polymath/boolean.py b/src/polymath/boolean.py index 8290f1f..f5bdf9e 100755 --- a/src/polymath/boolean.py +++ b/src/polymath/boolean.py @@ -1,6 +1,13 @@ ########################################################################################## -# polymath/boolean.py: Boolean subclass of PolyMath base class +# polymath/boolean.py ########################################################################################## +"""The :class:`~polymath.Boolean` subclass, representing True and False values. + +A Boolean is a :class:`~polymath.Scalar` whose values are booleans. Arithmetic on a +Boolean first converts it to an integer Scalar, so ``True`` behaves as one and ``False`` +as zero. Masked elements make a Boolean three-valued; see :mod:`polymath.extensions.tvl` +for the operations that treat a masked value as "maybe" rather than as an error. +""" import numpy as np @@ -13,8 +20,8 @@ class Boolean(Scalar): """Represent boolean values in the PolyMath framework. - This class handles boolean values with masking support. Masked values are - considered unknown, neither True nor False. + This class handles boolean values with masking support. Masked values are considered + unknown, neither True nor False. """ _NRANK = 0 # The number of numerator axes. @@ -31,7 +38,7 @@ def as_boolean(arg, *, recursive=True): """Convert the argument to Boolean if possible. Parameters: - arg (object): The object to convert to Boolean. + arg (BooleanLike): The object to convert to Boolean. recursive (bool, optional): This parameter is ignored for Boolean class but included for compatibility. @@ -51,7 +58,8 @@ def as_index(self): """An object suitable for indexing a NumPy ndarray. Returns: - numpy.ndarray: A boolean array with False values where masked. + numpy.ndarray | bool: A boolean or boolean array with False values where + values are False or masked. """ return (self._values & self.antimask) @@ -64,24 +72,25 @@ def sum(self, axis=None, *, value=True, builtins=None, recursive=True, masked=No values instead of True values. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The sum is - determined across these axes, leaving any remaining axes in the returned - value. If None (the default), then the sum is performed across all axes of - the object. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of + axes. The sum is determined across these axes, leaving any remaining axes + in the returned value. If None (the default), then the sum is performed + across all axes of the object. value (bool, optional): True to count True values; False to count False values. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int or float instead of as an instance - of Qube. Default is that specified by Qube.prefer_builtins(). + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int instead of as an instance + of Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. recursive (bool, optional): Ignored for class Boolean. - masked (bool, optional): The value to return if builtins is True but the - returned value is masked. Default is to return a masked value instead of a - builtin type. - out (object, optional): Ignored. Enables "np.sum(Qube)" to work. + masked (int | None, optional): The value to return if `builtins` is True but + the returned value is masked. Default is to return a masked value instead + of a builtin type. + out (Any, optional): Ignored. Enables ``np.sum(Boolean)`` to work. Returns: - Scalar: The sum of matched values (True or False) along the specified axis or - axes. + Scalar | int: The count of matching values (True or False) along the + specified axis or axes. """ if value: @@ -114,8 +123,7 @@ def __pos__(self, *, recursive=True): recursive (bool, optional): Ignored for Boolean. Returns: - Scalar: An integer Scalar with ones where this object is True, zeros where - False. + Scalar: Ones where this object is True, zeros where False. """ return self.as_int() @@ -143,178 +151,201 @@ def __abs__(self, *, recursive=True): recursive (bool, optional): Ignored for Boolean. Returns: - Scalar: An integer Scalar with ones where this object is True, zeros where - False. + Scalar: Ones where this object is True, zeros where False. """ return self.as_int() def __add__(self, /, arg, *, recursive=True): - """self + arg, element-by-element addition after this Boolean is converted to an - integer Scalar. + """``self + arg``, element-by-element addition after this Boolean is converted to + an integer Scalar. This is an override of :meth:`Qube.__add__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The sum. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() + arg def __radd__(self, /, arg, *, recursive=True): - """arg + self, element-by-element addition after this Boolean is converted to an - integer Scalar. + """``arg + self``, element-by-element addition after this Boolean is converted to + an integer Scalar. This is an override of :meth:`Qube.__radd__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The sum. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() + arg def __iadd__(self, /, arg): - """self += arg; in-place addition is not supported for Boolean. + """``self += arg``; in-place addition is not supported for Boolean. This is an override of :meth:`Qube.__iadd__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place addition is not supported for Boolean. + TypeError: Always; in-place addition is not supported for Boolean. """ Qube._raise_unsupported_op('+=', self) def __sub__(self, /, arg, *, recursive=True): - """self - arg, element-by-element subtraction after this Boolean is converted to - an integer Scalar. + """``self - arg``, element-by-element subtraction after this Boolean is converted + to an integer Scalar. This is an override of :meth:`Qube.__sub__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The difference. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() - arg def __rsub__(self, /, arg, *, recursive=True): - """arg - self, element-by-element subtraction after this Boolean is converted to - an integer Scalar. + """``arg - self``, element-by-element subtraction after this Boolean is converted + to an integer Scalar. This is an override of :meth:`Qube.__rsub__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The difference. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return -self.as_int() + arg def __isub__(self, /, arg): - """self -= arg; in-place subtraction is not supported for Boolean. + """``self -= arg``; in-place subtraction is not supported for Boolean. This is an override of :meth:`Qube.__isub__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place subtraction is not supported for Boolean. + TypeError: Always; in-place subtraction is not supported for Boolean. """ Qube._raise_unsupported_op('-=', self) def __mul__(self, /, arg, *, recursive=True): - """self * arg, element-by-element multiplication after this Boolean is converted - to an integer Scalar. + """``self * arg``, element-by-element multiplication after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__mul__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The product. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() * arg def __rmul__(self, /, arg, *, recursive=True): - """arg * self, element-by-element multiplication after this Boolean is converted - to an integer Scalar. + """``arg * self``, element-by-element multiplication after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__rmul__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The product. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() * arg def __imul__(self, /, arg): - """In-place multiplication is not supported for Boolean. + """``self *= arg``; in-place multiplication is not supported for Boolean. This is an override of :meth:`Qube.__imul__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place multiplication is not supported for Boolean. + TypeError: Always; in-place multiplication is not supported for Boolean. """ Qube._raise_unsupported_op('*=', self) def __truediv__(self, /, arg, *, recursive=True): - """self / arg, element-by-element division after this Boolean is converted to a - floating-point Scalar. + """``self / arg``, element-by-element division after this Boolean is converted to + a floating-point Scalar. This is an override of :meth:`Qube.__truediv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The quotient. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_float() / arg def __rtruediv__(self, /, arg, *, recursive=True): - """arg / self, element-by-element division after this Boolean is converted to a - floating-point Scalar. + """``arg / self``, element-by-element division after this Boolean is converted to + a floating-point Scalar. This is an override of :meth:`Qube.__rtruediv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. recursive (bool, optional): Ignored for Boolean. Returns: Scalar: The quotient. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ if not isinstance(arg, Qube): @@ -323,45 +354,51 @@ def __rtruediv__(self, /, arg, *, recursive=True): return arg / self.as_float() def __itruediv__(self, /, arg): - """self /= arg; in-place division is not supported for Boolean. + """``self /= arg``; in-place division is not supported for Boolean. This is an override of :meth:`Qube.__itruediv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place division is not supported for Boolean. + TypeError: Always; in-place division is not supported for Boolean. """ Qube._raise_unsupported_op('/=', self) def __floordiv__(self, /, arg): - """self // arg, element-by-element floor division after this Boolean is converted - to an integer Scalar. + """``self // arg``, element-by-element floor division after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__floordiv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Returns: Scalar: The result of the floor division. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() // arg def __rfloordiv__(self, /, arg): - """arg // self, element-by-element floor division after this Boolean is converted - to an integer Scalar. + """``arg // self``, element-by-element floor division after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__rfloordiv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Returns: Scalar: The result of the floor division. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ if not isinstance(arg, Qube): @@ -370,45 +407,51 @@ def __rfloordiv__(self, /, arg): return arg // self.as_int() def __ifloordiv__(self, /, arg): - """self //= arg; in-place division is not supported for Boolean. + """``self //= arg``; in-place floor division is not supported for Boolean. This is an override of :meth:`Qube.__ifloordiv__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place floor division is not supported for Boolean. + TypeError: Always; in-place floor division is not supported for Boolean. """ Qube._raise_unsupported_op('//=', self) def __mod__(self, /, arg): - """self % arg, element-by-element modulus after this Boolean is converted to an - integer Scalar. + """``self % arg``, element-by-element modulus after this Boolean is converted to + an integer Scalar. This is an override of :meth:`Qube.__mod__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Returns: Scalar: The remainder. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ return self.as_int() % arg def __rmod__(self, /, arg): - """arg % self, element-by-element modulus after this Boolean is converted to an - integer Scalar. + """``arg % self``, element-by-element modulus after this Boolean is converted to + an integer Scalar. This is an override of :meth:`Qube.__rmod__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Returns: Scalar: The remainder. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ if not isinstance(arg, Qube): @@ -417,30 +460,33 @@ def __rmod__(self, /, arg): return arg % self.as_int() def __imod__(self, /, arg): - """Raise exception as in-place modulo is not supported for Boolean. + """``self %= arg``; in-place modulo is not supported for Boolean. This is an override of :meth:`Qube.__imod__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The argument. + arg (Any): The argument. Raises: - ValueError: Always; in-place modulo is not supported for Boolean. + TypeError: Always; in-place modulo is not supported for Boolean. """ Qube._raise_unsupported_op('%=', self) def __pow__(self, /, arg): - """self ** arg, element-by-element exponentiation after this Boolean is converted - to an integer Scalar. + """``self ** arg``, element-by-element exponentiation after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__pow__`. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The exponent. + arg (Any): The exponent. Returns: Scalar: The result of the exponentiation. + + Raises: + ValueError: If shapes are incompatible or `arg` has denominators. """ arg = Scalar.as_scalar(arg) @@ -464,10 +510,10 @@ def __ipow__(self, /, arg): the result cannot be stored back into a Boolean. Parameters: - arg (Qube, numpy.ndarray, float, int, or bool): The exponent. + arg (Any): The exponent. Raises: - ValueError: Always; in-place exponentiation is not supported for Boolean. + TypeError: Always; in-place exponentiation is not supported for Boolean. """ Qube._raise_unsupported_op('**=', self) @@ -477,83 +523,83 @@ def __ipow__(self, /, arg): ###################################################################################### def __le__(self, arg, *, builtins=True): - """self <= arg, element-by-element "less than or equal" after this Boolean is - converted to integer Scalar. + """``self <= arg``, element-by-element "less than or equal" after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__le__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this int value is less than or equal to the + Boolean | bool: True where this int value is less than or equal to the argument. Raises: - ValueError: If either object has denominators. + ValueError: If the shapes are incompatible. """ return self.as_int().__le__(arg, builtins=builtins) def __lt__(self, arg, *, builtins=True): - """self < arg, element-by-element "less than" after this Boolean is converted to - an integer Scalar. + """``self < arg``, element-by-element "less than" after this Boolean is converted + to an integer Scalar. This is an override of :meth:`Qube.__lt__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this int value is less than the argument. + Boolean | bool: True where this int value is less than the argument. Raises: - ValueError: If either object has denominators. + ValueError: If the shapes are incompatible. """ return self.as_int().__lt__(arg, builtins=builtins) def __ge__(self, arg, *, builtins=True): - """self <= arg, element-by-element "greater than or equal" after this Boolean is - converted to integer Scalar. + """``self >= arg``, element-by-element "greater than or equal" after this Boolean + is converted to an integer Scalar. This is an override of :meth:`Qube.__ge__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this int value is greater than or equal to the + Boolean | bool: True where this int value is greater than or equal to the argument. Raises: - ValueError: If either object has denominators. + ValueError: If the shapes are incompatible. """ return self.as_int().__ge__(arg, builtins=builtins) def __gt__(self, arg, *, builtins=True): - """self > arg, element-by-element "greater than" after this Boolean is converted - to an integer Scalar. + """``self > arg``, element-by-element "greater than" after this Boolean is + converted to an integer Scalar. This is an override of :meth:`Qube.__gt__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this int value is greater than the argument. + Boolean | bool: True where this int value is greater than the argument. Raises: - ValueError: If either object has denominators. + ValueError: If the shapes are incompatible. """ return self.as_int().__gt__(arg, builtins=builtins) @@ -564,7 +610,7 @@ def __gt__(self, arg, *, builtins=True): Boolean.TRUE = Boolean(True).as_readonly() Boolean.FALSE = Boolean(False).as_readonly() -Boolean.MASKED = Boolean(False, True).as_readonly() +Boolean.MASKED = Boolean(False, mask=True).as_readonly() ########################################################################################## # Once the load is complete, we can fill in a reference to the Boolean class diff --git a/src/polymath/boolean.pyi b/src/polymath/boolean.pyi deleted file mode 100644 index 0cdd5f3..0000000 --- a/src/polymath/boolean.pyi +++ /dev/null @@ -1,65 +0,0 @@ -########################################################################################## -# polymath/boolean.pyi -########################################################################################## -"""Type stub for :mod:`polymath.boolean`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -from typing import Any - -from numpy.typing import NDArray - -from polymath.qube import _Arraylike -from polymath.scalar import Scalar - -__all__ = ['Boolean'] - -class Boolean(Scalar): - FALSE: Boolean - MASKED: Boolean - TRUE: Boolean - def __abs__(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __add__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __floordiv__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[override] - def __ge__(self, arg: Any, *, # type: ignore[override] - builtins: bool = ...) -> _Arraylike | bool: ... - def __gt__(self, arg: Any, *, # type: ignore[override] - builtins: bool = ...) -> _Arraylike | bool: ... - def __iadd__(self, arg: _Arraylike) -> Any: ... # type: ignore[misc, override] - def __ifloordiv__(self, arg: _Arraylike) -> Any: ... - def __imod__(self, arg: _Arraylike) -> Any: ... # type: ignore[override] - def __imul__(self, arg: _Arraylike) -> Any: ... # type: ignore[misc, override] - def __ipow__(self, arg: _Arraylike) -> Any: ... # type: ignore[override] - def __isub__(self, arg: _Arraylike) -> Any: ... # type: ignore[misc, override] - def __itruediv__(self, arg: _Arraylike) -> Any: ... # type: ignore[misc, override] - def __le__(self, arg: Any, *, # type: ignore[override] - builtins: bool = ...) -> _Arraylike | bool: ... - def __lt__(self, arg: Any, *, # type: ignore[override] - builtins: bool = ...) -> _Arraylike | bool: ... - def __mod__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[override] - def __mul__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __neg__(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __pos__(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __pow__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[override] - def __radd__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[misc, override] - def __rfloordiv__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[override] - def __rmod__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[override] - def __rmul__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[misc, override] - def __rsub__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __rtruediv__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __sub__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __truediv__(self, arg: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - @staticmethod - def as_boolean(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def as_index(self) -> NDArray[Any]: ... # type: ignore[override] - def identity(self) -> _Arraylike: ... - def sum(self, axis: Any = ..., *, value: bool = ..., builtins: bool | None = ..., - recursive: bool = ..., masked: bool | None = ..., - out: Any = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/extensions/__init__.py b/src/polymath/extensions/__init__.py index 986f646..1a93729 100755 --- a/src/polymath/extensions/__init__.py +++ b/src/polymath/extensions/__init__.py @@ -1,6 +1,17 @@ -################################################################################ +########################################################################################## # polymath/extensions/__init__.py -################################################################################ +########################################################################################## +"""Bind the methods defined throughout this subpackage onto :class:`~polymath.Qube`. + +Only what defines a PolyMath object lives in :mod:`polymath.qube`; everything else is +written as a plain function in one of the modules here and attached to the class by this +module. Importing it is therefore a prerequisite for using any PolyMath class, and +:mod:`polymath` imports it before any subclass module, because each subclass builds +read-only constants as it loads and those constructions call the bound methods. + +No module in this subpackage may import a subclass at module level, for the same reason. +Class references such as ``Qube._SCALAR_CLASS`` are provided for that purpose. +""" from polymath.qube import Qube @@ -291,4 +302,4 @@ # This module exports no names of its own; it binds the extension methods onto Qube. __all__ = [] -################################################################################ +########################################################################################## diff --git a/src/polymath/extensions/attr_ops.py b/src/polymath/extensions/attr_ops.py index 5e30390..cc7b77f 100644 --- a/src/polymath/extensions/attr_ops.py +++ b/src/polymath/extensions/attr_ops.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/attr_ops.py: Custom attribute operations ########################################################################################## +"""Support for user-defined attributes on a PolyMath object. + +The single function here attaches an arbitrary named value to an object, so that +application-specific information can travel with it. The attribute is carried along by the +operations that copy an object. +""" __all__ = ['add_attr'] @@ -22,9 +28,9 @@ def add_attr(self, name, value=None): beginning with "d_d" are reserved for derivatives and are never allowed. Parameters: - name (str): The name of the attribute, which must be a valid Python identifier - and must not begin with "d_d". - value (object, optional): The value of the attribute; None by default. + name (str): The name of the attribute, which must be a valid Python identifier and + must not begin with "d_d". + value (Any, optional): The value of the attribute; None by default. Returns: Qube: This object after the attribute has been added. diff --git a/src/polymath/extensions/broadcaster.py b/src/polymath/extensions/broadcaster.py index c907377..a159fc8 100644 --- a/src/polymath/extensions/broadcaster.py +++ b/src/polymath/extensions/broadcaster.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/broadcaster.py: broadcast operations ########################################################################################## +"""Broadcasting of PolyMath objects to a common shape. + +These functions follow the NumPy broadcasting rules, applied to the leading array axes of +an object rather than to its items, so that objects of different shapes can be combined. +A broadcast object shares memory with its source and is therefore read-only. +""" import numpy as np from polymath.qube import Qube @@ -9,18 +15,20 @@ def broadcast_into_shape(self, shape, *, recursive=True, _protected=True): - """This object broadcasted to the specified shape. DEPRECATED name; use broadcast_to. + """This object broadcasted to the specified shape. + + This is an alternative name for :meth:`~polymath.Qube.broadcast_to`. Parameters: - shape (tuple): The shape into which the object is to be broadcast. + shape (tuple[int, ...]): The shape into which the object is to be broadcast. recursive (bool, optional): True to broadcast the derivatives as well. Otherwise, they are removed. - _protected (bool, optional): False to prevent the arrays being set to readonly. + _protected (bool, optional): False to prevent the arrays being set to read-only. Note that this is a potentially dangerous option, because some elements of the returned array share memory with one another and with the original object. Returns: - The broadcasted object; self if the shape already matches. + Qube: The broadcasted object; self if the shape already matches. Notes: Both the original object and the returned array are normally set to read-only, @@ -36,15 +44,16 @@ def broadcast_to(self, shape, *, recursive=True, _protected=True): """This object broadcasted to the specified shape. Parameters: - shape (tuple): The shape into which the object is to be broadcast. + shape (tuple[int, ...]): The shape into which the object is to be broadcast. recursive (bool, optional): True to broadcast the derivatives as well. Otherwise, they are removed. - _protected (bool, optional): False to prevent the arrays being set to readonly. + _protected (bool, optional): False to prevent the arrays being set to read-only. Note that this is a potentially dangerous option, because some elements of the returned array share memory with one another and with the original object. Returns: - The broadcasted object; self if the shape already matches. + Qube: The broadcasted object. If the shape already matches, this object is + returned, or a shallow copy without derivatives if `recursive` is False. Notes: Both the original object and the returned array are normally set to read-only, @@ -121,20 +130,21 @@ def broadcast_to(self, shape, *, recursive=True, _protected=True): return obj +@staticmethod def broadcasted_shape(*objects, item=()): """The shape defined by a broadcast across the objects. Parameters: - *objects (Qube, array-like, int, float, None, or tuple): Zero or more array - objects. Values of None are assigned shape (). A list or tuple is treated as - the definition of an additional shape. - item (list or tuple, optional): A list or tuple to be appended to the shape. - This makes it possible to use the returned shape in the declaration of a NumPy - array containing items that are not scalars. + *objects (QubeLike | None): Zero or more array objects. Values of None are + assigned shape (). A list or tuple is treated as the definition of an + additional shape. + item (tuple[int, ...] | list[int], optional): A shape to be appended to the + result. This makes it possible to use the returned shape in the declaration + of a NumPy array containing items that are not scalars. Returns: - The broadcast shape, comprising the maximum value of each corresponding axis, with - the `item` shape appended if any. + tuple[int, ...]: The broadcast shape, comprising the maximum value of each + corresponding axis, with the `item` shape appended if any. Raises: ValueError: If an object dimension is incompatible with the broadcast. @@ -185,24 +195,24 @@ def broadcasted_shape(*objects, item=()): return tuple(new_shape) + tuple(item) +@staticmethod def broadcast(*objects, recursive=True, _protected=True): - """Broadcast one or objects to their common shape. + """Broadcast one or more objects to their common shape. Python scalars are returned unchanged because they already broadcast with anything. Parameters: - *objects (Qube, array-like, int, float, None, or tuple): - Zero or more array objects. Values of None are assigned shape (). A list or - tuple is treated as the definition of an additional shape. + *objects (QubeLike | None): Zero or more array objects. Values of None are + assigned shape (). A list or tuple is treated as the definition of an + additional shape. recursive (bool, optional): True to broadcast the derivatives to the same shape; False to strip the derivatives from the returned objects. - _protected (bool, optional): False to prevent the arrays being set to readonly. + _protected (bool, optional): False to prevent the arrays being set to read-only. Note that this is a potentially dangerous option, because memory is shared among the elements within each of the returned objects. Returns: - A tuple of objects, all broadcased to the common shape. Python scalars are - returned unchanged because they already broadcast with anything. + tuple[Any, ...]: A tuple of objects, all broadcast to the common shape. Raises: ValueError: If an object dimension is incompatible with the broadcast. diff --git a/src/polymath/extensions/casting.py b/src/polymath/extensions/casting.py index b0fd6d7..06a44d5 100644 --- a/src/polymath/extensions/casting.py +++ b/src/polymath/extensions/casting.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/casting.py: Value tests and conversions between Qube subclasses ########################################################################################## +"""Tests of single values and conversions between PolyMath classes. + +Two kinds of operation are collected here. The first identifies a lone value, as opposed +to an array, and recognizes a single boolean True or False. The second converts an object +to another :class:`~polymath.Qube` subclass whose item shape is compatible, and builds the +constant and size-zero variants of an object. +""" import numpy as np import numbers @@ -12,7 +19,14 @@ @staticmethod def as_one_bool(value): - """Convert a single value to a bool; leave other values unchanged.""" + """Convert a single value to a bool; leave other values unchanged. + + Parameters: + value (Any): The value to convert. + + Returns: + Any: A Python bool if `value` is not a NumPy array; otherwise `value` unchanged. + """ if not isinstance(value, np.ndarray): return bool(value) @@ -22,7 +36,14 @@ def as_one_bool(value): @staticmethod def is_one_true(value): - """True if the value is a single boolean True.""" + """True if the value is a single boolean True. + + Parameters: + value (Any): The value to test. + + Returns: + bool: True if `value` is a Python or NumPy boolean equal to True. + """ if isinstance(value, (bool, np.bool_)): return bool(value) @@ -32,7 +53,14 @@ def is_one_true(value): @staticmethod def is_one_false(value): - """True if the value is a single boolean False.""" + """True if the value is a single boolean False. + + Parameters: + value (Any): The value to test. + + Returns: + bool: True if `value` is a Python or NumPy boolean equal to False. + """ if isinstance(value, (bool, np.bool_)): return not bool(value) @@ -42,7 +70,14 @@ def is_one_false(value): @staticmethod def _is_one_value(value): - """True if the value is a Python numeric or a NumPy numeric scalar.""" + """True if the value is a Python numeric or a NumPy numeric scalar. + + Parameters: + value (Any): The value to test. + + Returns: + bool: True if `value` is a single number rather than an array or sequence. + """ if isinstance(value, _NUMERIC_TYPES): return True @@ -60,16 +95,19 @@ def as_this_type(self, arg, *, recursive=True, coerce=True, op=''): If the object is already of the correct class and type, it is returned unchanged. Parameters: - arg (array-like, float, int, or bool): The object to the class of this object. - If the argument is a scalar or NumPy ndarray, a new instance of this - object's class is created. + arg (QubeLike): The object to convert to the class of this object. If the + argument is a scalar or NumPy ndarray, a new instance of this object's class + is created. recursive (bool, optional): True to convert the derivatives as well. - coerce (bool, optional): True to coerce the data type silently; False to leave - the data type unchanged. + coerce (bool, optional): True to coerce the data type silently; False to leave the + data type unchanged. op (str, optional): Name of operator to use in an error message. Returns: Qube: The argument converted to the type of this object. + + Raises: + ValueError: If the numerator of `arg` is incompatible with that of this object. """ # If the classes already match, we might return the argument as is @@ -147,11 +185,11 @@ def _deriv_classes(classes): substitute, which replaces it here. Parameters: - classes (type, list, or tuple): One class or a list of candidate classes, as - :meth:`cast` accepts. + classes (type | list[type] | tuple[type, ...]): One class or a list of candidate + classes, as :meth:`cast` accepts. Returns: - tuple: The candidate classes for a derivative, in the same order. + tuple[type, ...]: The candidate classes for a derivative, in the same order. """ if isinstance(classes, type): @@ -190,12 +228,13 @@ def _castable_to(self, cls): return cls._BOOLS_OK -def cast(self, classes): - """A shallow copy of this object casted to another Qube subclass. +def cast(self, *, classes=()): + """A shallow copy of this object cast to another Qube subclass. Parameters: - classes (type or list): A Qube subclass or list of subclasses. The object - will be casted to the first suitable class in the list. + classes (type | list[type] | tuple[type, ...], optional): A Qube subclass or + list of subclasses. The object will be cast to the first suitable class in + the list. If the list is empty (the default), the object is returned as is. Returns: Qube: A shallow copy of this object. If the object is already of the selected @@ -245,9 +284,11 @@ def as_all_constant(self, constant=None, *, recursive=True): Derivatives are all set to zero. The mask is unchanged. Parameters: - constant (array-like, float, int, or bool, optional): The constant value for - each item. This must have the same shape as this object's items. Use None - for values of zero appropriate to the Qube subclass. + constant (QubeLike | None, optional): The constant value for each item. This must + have the same shape as this object's items. Use None for values of zero + appropriate to the Qube subclass. + recursive (bool, optional): True to include the derivatives, each also set to a + constant value of zero. Returns: Qube: A shallow copy of this object with constant values. @@ -273,9 +314,11 @@ def as_size_zero(self, axis=0, *, recursive=True): """A shallow, read-only copy of this object with size zero. Parameters: - axis (int, optional): The axis index (positive or negative) to collapse to + axis (int | None, optional): The axis index (positive or negative) to collapse to length zero; the other axes are left unchanged. Use None for an object of shape (0,). + recursive (bool, optional): True to include the derivatives, each also reduced to + size zero. Returns: Qube: A shallow copy of this object with size zero. diff --git a/src/polymath/extensions/deriv_ops.py b/src/polymath/extensions/deriv_ops.py index 772899b..4f3ab25 100644 --- a/src/polymath/extensions/deriv_ops.py +++ b/src/polymath/extensions/deriv_ops.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/deriv_ops.py: Derivative operations ########################################################################################## +"""Management of the derivatives attached to a PolyMath object. + +A PolyMath object can carry named partial derivatives, each one another PolyMath object +whose denominator shape describes the variable of differentiation. These functions insert, +rename, and delete derivatives, and return copies of an object with or without them. +""" from polymath.qube import Qube @@ -19,28 +25,28 @@ def insert_deriv(self, key, deriv, *, override=True): Derivatives cannot be integers. They are converted to floating-point if necessary. You cannot replace the pre-existing value of a derivative in a read-only object - unless you explicit set override=True. However, inserting a new derivative into a + unless you explicitly set `override=True`. However, inserting a new derivative into a read-only object is not prevented. Parameters: - key (str): The name of the derivative. Each derivative also becomes accessible - as an object attribute with "d_d" in front of the name. For example, the + key (str): The name of the derivative. Each derivative also becomes accessible as + an object attribute with "d_d" in front of the name. For example, the time-derivative of this object might be keyed by "t", in which case it can also be accessed as attribute "d_dt". - deriv (Qube): The derivative. Derivatives must have the same leading shape and - the same numerator as the object; denominator items are used for partial + deriv (Qube): The derivative. Derivatives must have the same leading shape and the + same numerator as the object; denominator items are used for partial derivatives. - override (bool, optional): True to allow the value of a pre-existing - derivative to be replaced. + override (bool, optional): True to allow the value of a pre-existing derivative to + be replaced. Returns: Qube: This object after the derivative has been inserted. Raises: - TypeError: If the derivative class is invalid or if derivatives are disallowed - for the object class. - ValueError: If the shape is invalid, or if the key already exists when - `override` is False. + TypeError: If the derivative class is invalid or if derivatives are disallowed for + the object class. + ValueError: If the shape is invalid, or if the key already exists when `override` + is False. """ if not self._DERIVS_OK: @@ -82,21 +88,22 @@ def insert_derivs(self, derivs, *, override=False): """Insert or replace the derivatives in this object from a dictionary. You cannot replace the pre-existing values of any derivative in a read-only object - unless you explicit set override=True. However, inserting a new derivative into a + unless you explicitly set `override=True`. However, inserting a new derivative into a read-only object is not prevented. Parameters: - derivs (dict): The dictionary of derivatives keyed by their names. - override (bool, optional): True to allow the value of a pre-existing - derivative to be replaced. + derivs (dict[str, Qube]): The dictionary of derivatives keyed by their names. + override (bool, optional): True to allow the value of a pre-existing derivative to + be replaced. Returns: - Qube: This object after the derivatives has been inserted. + Qube: This object after the derivatives have been inserted. Raises: - TypeError: If a derivative class is invalid. - ValueError: If derivatives are disallowed for the object, if a shape is - invalid, or if a key already exists when `override` is False. + TypeError: If a derivative class is invalid or if derivatives are disallowed for + the object class. + ValueError: If a shape is invalid, or if a key already exists when `override` is + False. """ # Check every insert before proceeding with any @@ -117,11 +124,11 @@ def delete_deriv(self, key, *, override=False): """Delete a single derivative from this object, given the key. Derivatives cannot be deleted from a read-only object without explicitly setting - override=True. + `override=True`. Parameters: - key (str): The key of the derivative to remove. If the key does not exist, - the object is unchanged. + key (str): The key of the derivative to remove. If the key does not exist, the + object is unchanged. override (bool, optional): True to allow the deleting of derivatives from a read-only object. @@ -148,8 +155,8 @@ def delete_derivs(self, *, override=False, preserve=None): Parameters: override (bool, optional): True to allow the deleting of derivatives from a read-only object. - preserve (list, tuple or set, optional): The names of derivatives to retain. - All others are removed. + preserve (str | list[str] | tuple[str, ...] | set[str] | None, optional): The + name or names of derivatives to retain. All others are removed. Raises: ValueError: If this object is read-only and `override` is False. @@ -182,8 +189,8 @@ def without_derivs(self, *, preserve=None): A read-only object remains read-only, and is cached for later use. Parameters: - preserve (list, tuple, or set, optional): The names of derivatives to retain. - All others are removed. + preserve (str | list[str] | tuple[str, ...] | set[str] | None, optional): The + name or names of derivatives to retain. All others are removed. Returns: Qube: The copy, with the same subclass as self. @@ -219,11 +226,8 @@ def without_derivs(self, *, preserve=None): @property -def wod(self): - """A shallow clone without derivatives, cached. - - Read-only objects remain read-only. - """ +def wod(self) -> Qube: + """A cached shallow clone without derivatives; a read-only object stays read-only.""" if not self._derivs: return self @@ -262,27 +266,26 @@ def without_deriv(self, key): def with_deriv(self, key, value, *, method='insert'): - """A shallow copy of this object with a derivative inserted or - added. + """A shallow copy of this object with a derivative inserted or added. A read-only object remains read-only. Parameters: key (str): The key of the derivative to insert. value (Qube): The value for this derivative. - method (str): How to insert the derivative, one of these options:` + method (str, optional): How to insert the derivative, one of these options: - * "`insert`": Iinsert the new derivative; raise a ValueError if a - derivative of the same name already exists. - * "`replace`": Replace an existing derivative of the same name. - * "`add`": Add this derivative to an existing derivative of the same name. + * "insert": Insert the new derivative; raise a ValueError if a derivative of + the same name already exists. + * "replace": Replace an existing derivative of the same name. + * "add": Add this derivative to an existing derivative of the same name. Returns: Qube: The copy, with the same subclass as self. Raises: ValueError: If `method` is "insert" and a derivative of the given name already - exists. + exists, or if `method` is not one of the options above. """ result = self.clone(recursive=True) @@ -309,20 +312,21 @@ def rename_deriv(self, key, new_key, *, method='insert'): Parameters: key (str): The current key of the derivative. new_key (str): The new name of the derivative. - method (str): How to rename the derivative, one of these options:` + method (str, optional): How to insert the renamed derivative, one of these + options: - * "`insert`": Iinsert the new derivative; raise a ValueError if a - derivative of the same name already exists. - * "`replace`": Replace an existing derivative of the same name. - * "`add`": Add this derivative to an existing derivative of the same name. + * "insert": Insert the renamed derivative; raise a ValueError if a derivative + named `new_key` already exists. + * "replace": Replace an existing derivative named `new_key`. + * "add": Add this derivative to an existing derivative named `new_key`. Returns: Qube: The copy, with the same subclass as self. Raises: KeyError: If the `key` derivative does not exist. - ValueError: If `method` is "insert" and a derivative of the given name already - exists. + ValueError: If `method` is "insert" and a derivative named `new_key` already + exists, or if `method` is not one of the options above. """ result = self.with_deriv(new_key, self._derivs[key], method=method) diff --git a/src/polymath/extensions/dtypes.py b/src/polymath/extensions/dtypes.py index d970a90..8d47c29 100644 --- a/src/polymath/extensions/dtypes.py +++ b/src/polymath/extensions/dtypes.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/dtypes.py: Data type interpretation and conversion ########################################################################################## +"""Interpretation and conversion of the data type of a PolyMath object. + +Every PolyMath object holds floats, integers, or booleans. These functions report which of +the three applies, convert an object from one to another, and work out the data type, +value, and mask implied by an arbitrary constructor argument. +""" import numpy as np import numbers @@ -15,7 +21,14 @@ @staticmethod def _has_qube(arg): - """True if the given list or tuple contains a Qube somewhere within.""" + """True if the given list or tuple contains a Qube somewhere within. + + Parameters: + arg (Any): The object to search. + + Returns: + bool: True if a Qube appears anywhere inside a nested list or tuple. + """ if isinstance(arg, (list, tuple)): return (any(isinstance(item, Qube) for item in arg) or @@ -26,7 +39,14 @@ def _has_qube(arg): @staticmethod def _has_masked_array(arg): - """True if the given list or tuple contains a MaskedArray somewhere within.""" + """True if the given list or tuple contains a MaskedArray somewhere within. + + Parameters: + arg (Any): The object to search. + + Returns: + bool: True if a MaskedArray appears anywhere inside a nested list or tuple. + """ if isinstance(arg, (list, tuple)): return (any(isinstance(item, np.ma.MaskedArray) for item in arg) or @@ -37,15 +57,14 @@ def _has_masked_array(arg): @staticmethod def _as_values_and_mask(arg, opstr=''): - """This object converted to a scalar or Numpy array with optional mask. + """This argument converted to a scalar or NumPy array with optional mask. Parameters: - arg: object to convert to a scalar or array. - opstr (str, optional): Name of operation string to include in any error - message. + arg (QubeLike): The object to convert to a scalar or array. + opstr (str, optional): Name of operation to include in any error message. Returns: - tuple: (`value`, `mask`) as inferred from `arg`. + tuple[ValsType, MaskType]: (`value`, `mask`) as inferred from `arg`. Raises: TypeError: If the data type of `arg` is invalid. @@ -99,14 +118,14 @@ def _dtype_and_value(arg, masked_value=0, opstr=''): as an array with its original dtype. Parameters: - arg (Qube, array-like, float, int, or bool): Object to interpret. - masked_value (float, int, or bool): Value to use where `arg` is masked. + arg (QubeLike): Object to interpret. + masked_value (float | int | bool, optional): Value to use where `arg` is masked. opstr (str, optional): Name of operation to include in any error message. Returns: - tuple: (`dtype`, `value`), where `dtype` is one of "float", "int", or "bool", - and `value` is the result of converting `arg` to a NumPy.ndarray, float, - int, or bool. + tuple[str, ValsType]: (`dtype`, `value`), where `dtype` is one of "float", "int", + or "bool", and `value` is the result of converting `arg` to a numpy.ndarray, + float, int, or bool. Raises: TypeError: If the type of `arg` is invalid. @@ -178,16 +197,16 @@ def _dtype_and_value(arg, masked_value=0, opstr=''): @staticmethod def _array_dtype_and_value(arg, opstr=''): - """Tuple (dtype, value) for a NumPy array, where dtype is "float", "int", or - "bool". + """Tuple (dtype, value) for a NumPy array; dtype is one of "float", "int", or "bool". Parameters: arg (numpy.ndarray): Array to interpret. It must not be a MaskedArray. opstr (str, optional): Name of operation to include in any error message. Returns: - tuple: (`dtype`, `value`), where `dtype` is one of "float", "int", or "bool". - A shapeless array is returned as a Python scalar. + tuple[str, ValsType]: (`dtype`, `value`), where `dtype` is one of "float", "int", + or "bool", and `value` is the result of converting `arg` to a numpy.ndarray, + float, int, or bool. Raises: ValueError: If the dtype of `arg` is unsupported. @@ -212,27 +231,34 @@ def _array_dtype_and_value(arg, opstr=''): @staticmethod def _dtype(arg): - """dtype of the given argument, one of "float", "int", or "bool".""" + """The dtype of the given argument, one of "float", "int", or "bool". + + Parameters: + arg (QubeLike): The object whose dtype is to be determined. + + Returns: + str: One of "float", "int", or "bool". + """ return Qube._dtype_and_value(arg)[0] @staticmethod def _casted_to_dtype(arg, dtype, masked_value=0): - """This value casted to the specified dtype, one of "float", "int", or "bool". + """This value cast to the specified dtype, one of "float", "int", or "bool". An object that is already of the requested type is returned unchanged. Note that converting floats to ints is always a "floor" operation, so -1.5 -> -2. Parameters: - arg (Qube, array-like, float, int, or bool): Object to cast - dtype (str): dtype to cast to, one of float", "int", or "bool". - masked_value (float, int, or bool): Value to assign to a masked item in the - case where the input argument is a Qube or MaskedArray. + arg (QubeLike): Object to cast. + dtype (str): The dtype to cast to, one of "float", "int", or "bool". + masked_value (float | int | bool, optional): Value to assign to a masked item in + the case where the input argument is a Qube or MaskedArray. Returns: - (numpy.ndarray, float, int, or bool): The result of the cast. + numpy.ndarray | float | int | bool: The result of the cast. """ if isinstance(arg, (list, tuple)): @@ -296,8 +322,10 @@ def _suitable_dtype(cls, dtype='float', opstr=''): Parameters: cls (type): Qube subclass. - dtype (str, optional): Default dtype, one of "float", "int", or "bool", to - return if it is compatible with the subclass. + dtype (str | numpy.dtype, optional): Requested dtype, one of "float", "int", or + "bool", or any NumPy dtype of one of those kinds. It is returned as one of + the three names if it is compatible with the subclass; otherwise the closest + compatible dtype is returned. opstr (str, optional): Name of the operation to include in any error message. Returns: @@ -353,12 +381,12 @@ def _suitable_numer(cls, numer=None, opstr=''): Parameters: cls (type): Qube subclass. - numer (tuple, optional): Numerator shape to make suitable for use; None to - return the default numerator shape for this Qube subclass. + numer (tuple[int, ...] | None, optional): Numerator shape to make suitable for + use; None to return the default numerator shape for this Qube subclass. opstr (str, optional): Name of operation to include in any error message. Returns: - tuple: Numerator shape. + tuple[int, ...]: Numerator shape. Raises: ValueError: If `numer` is unspecified and `cls` does not have a default. @@ -391,18 +419,18 @@ def _suitable_value(cls, arg, *, numer=None, denom=(), expand=True, opstr=''): Parameters: cls (type): Qube subclass. - arg (Qube, array-like, float, int, or bool): Object to be made suitable. - numer (tuple, optional): Numerator shape; None for class default. - denom (tuple, optional): Denominator shape. - expand (bool, optional): True to expand the shape of the returned argument to - the minimum required for the class; False to leave it with its original - shape. + arg (QubeLike): Object to be made suitable. + numer (tuple[int, ...] | None, optional): Numerator shape; None for class default. + denom (tuple[int, ...], optional): Denominator shape. + expand (bool, optional): True to expand the shape of the returned argument to the + minimum required for the class; False to leave it with its original shape. opstr (str, optional): Name of operation to include in any error message. Returns: - (numpy.ndarray, float, int, or bool): The value made suitable for `cls`. + numpy.ndarray | float | int | bool: The value made suitable for `cls`. Raises: + TypeError: If the type of `arg` is invalid. ValueError: If `arg` is incompatible with `cls`. """ @@ -434,13 +462,21 @@ def _suitable_value(cls, arg, *, numer=None, denom=(), expand=True, opstr=''): ########################################################################################## def dtype(self): - """One of "float", "int", or "bool", depending this object's value.""" + """One of "float", "int", or "bool", depending on this object's data type. + + Returns: + str: One of "float", "int", or "bool". + """ return Qube._dtype(self._values) def is_numeric(self): - """True if this object contains numbers; False if boolean.""" + """True if this object contains numbers; False if boolean. + + Returns: + bool: True if this object contains numbers rather than booleans. + """ if isinstance(self._values, (bool, np.bool_)): return False @@ -454,12 +490,10 @@ def as_numeric(self, *, recursive=True): Booleans are converted to Scalars. Parameters: - recursive (bool, optional): True to include any derivatives; False to remove - them. + recursive (bool, optional): True to include any derivatives; False to remove them. Returns: - Qube: This object if it is already numeric; a Boolean is converted to a - Scalar. + Qube: This object if it is already numeric; otherwise an integer Scalar. """ if self.is_numeric(): @@ -470,7 +504,11 @@ def as_numeric(self, *, recursive=True): def is_float(self): - """True if this object contains floats; False if ints or booleans.""" + """True if this object contains floats; False if ints or booleans. + + Returns: + bool: True if this object contains floats. + """ if isinstance(self._values, np.ndarray): return self._values.dtype.kind == 'f' @@ -483,15 +521,14 @@ def as_float(self, *, recursive=True, copy=False, builtins=False): Booleans are converted to Scalars. Parameters: - recursive (bool, optional): True to include any derivatives; False to remove - them. - copy (bool, optional): True to ensure that a new object with an independent - copy of the values is returned. - builtins (bool, optional): True to return a Python float if the returned value - has shape (), is unmasked, and has no derivatives. + recursive (bool, optional): True to include any derivatives; False to remove them. + copy (bool, optional): True to ensure that a new object with an independent copy + of the values is returned. + builtins (bool, optional): True to return a Python float if the returned value has + shape (), is unmasked, and has no derivatives. Returns: - Qube: The result. + Qube | float: The result. Raises: TypeError: If this object cannot contain floats. @@ -525,7 +562,11 @@ def as_float(self, *, recursive=True, copy=False, builtins=False): def is_int(self): - """True if this object contains ints; False if floats or booleans.""" + """True if this object contains ints; False if floats or booleans. + + Returns: + bool: True if this object contains integers. + """ if isinstance(self._values, np.ndarray): return self._values.dtype.kind in 'iu' @@ -540,17 +581,17 @@ def as_int(self, *, copy=False, builtins=False): Booleans are converted to Scalars. Parameters: - copy (bool, optional): True to ensure that a new object with an independent - copy of the values is returned. - builtins (bool, optional): True to return a Python float if the returned value - has shape (), is unmasked, and has no derivatives. + copy (bool, optional): True to ensure that a new object with an independent copy + of the values is returned. + builtins (bool, optional): True to return a Python int if the returned value has + shape () and is unmasked. Returns: - Qube or int: The result. + Qube | int: The result. Raises: TypeError: If this object cannot contain integers. - """ + """ if builtins and self._is_scalar and not self._mask: return int(self._values) @@ -578,7 +619,11 @@ def as_int(self, *, copy=False, builtins=False): def is_bool(self): - """True if this object contains booleans; False otherwise.""" + """True if this object contains booleans; False otherwise. + + Returns: + bool: True if this object contains booleans. + """ if isinstance(self._values, np.ndarray): return self._values.dtype.kind == 'b' @@ -591,14 +636,14 @@ def as_bool(self, *, copy=False, builtins=False): Scalars are converted to Booleans. Parameters: - copy (bool, optional): True to ensure that a new object with an independent - copy of the values is returned. - builtins (bool, optional): True to return a Python float if the returned value - has shape (), is unmasked, and has no derivatives. + copy (bool, optional): True to ensure that a new object with an independent copy + of the values is returned. + builtins (bool, optional): True to return a Python bool if the returned value has + shape () and is unmasked. Returns: - Qube: A copy of object converted to bools; if the values are already bools and - `copy` is False, this object is returned unchanged. + Qube | bool: A copy of this object converted to bools; if the values are already + bools and `copy` is False, this object is returned unchanged. Raises: TypeError: If this object cannot contain bools. @@ -614,8 +659,7 @@ def as_bool(self, *, copy=False, builtins=False): if cls is Qube._SCALAR_CLASS: cls = Qube._BOOLEAN_CLASS - if not cls._INTS_OK: # pragma: no cover - # This should never happen + if not cls._BOOLS_OK: raise TypeError(f'{cls.__name__} object cannot contain bools') values = bool(self._values) if self._is_scalar else self._values.astype(np.bool_) diff --git a/src/polymath/extensions/errors.py b/src/polymath/extensions/errors.py index 43526f9..dcc1ca9 100644 --- a/src/polymath/extensions/errors.py +++ b/src/polymath/extensions/errors.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/errors.py: Error message support ########################################################################################## +"""Construction of the error messages raised by PolyMath operations. + +The functions here raise the exceptions that operations share, so that a message naming +the operation, the classes involved, and the offending shapes is phrased the same way +everywhere. They also perform the checks that many operations begin with, such as +requiring an object to have no denominator. +""" import numpy as np from polymath.qube import Qube @@ -34,6 +41,9 @@ def _disallow_denom(self, op): Parameters: op (str): Name of the operation to appear in the error message. + + Raises: + ValueError: If this object has a denominator. """ if self._drank: @@ -41,10 +51,13 @@ def _disallow_denom(self, op): def _require_scalar(self, op): - """Raise ValueError if this object has rank > 0. + """Raise ValueError if this object has a numerator rank greater than zero. Parameters: op (str): Name of the operation to appear in the error message. + + Raises: + ValueError: If this object has a numerator rank greater than zero. """ if self._nrank: @@ -58,10 +71,10 @@ def _require_axis_in_range(self, axis, rank, op, name='axis'): axis (int): Axis index, positive or negative. rank (int): Rank of an array for indexing. op (str): Name of the operation to appear in the error message. - name (str, optional): Name of axis variable. + name (str, optional): Name of the axis variable, for the error message. Raises: - ValueError: If axis < -rank or >= rank. + ValueError: If `axis` is less than -`rank` or greater than or equal to `rank`. """ if axis < -rank or axis >= rank: @@ -70,7 +83,19 @@ def _require_axis_in_range(self, axis, rank, op, name='axis'): def _raise_unsupported_op(op, /, obj1, obj2=None): - """Raise a TypeError or ValueError for unsupported operations.""" + """Raise a TypeError or ValueError for an unsupported operation. + + Parameters: + op (str): Name of the operation to appear in the error message. + obj1 (Qube): The left operand of the operation. + obj2 (QubeLike | None, optional): The right operand of the operation. If None, the + operation is reported as unsupported for `obj1` alone. + + Raises: + TypeError: If `obj2` is None or its type is unsupported. + ValueError: If either operand is a list, tuple or NumPy array, in which case the + item shapes are reported as incompatible. + """ opstr = obj1._opstr(op) @@ -96,7 +121,16 @@ def _raise_unsupported_op(op, /, obj1, obj2=None): def _raise_incompatible_shape(op, /, obj1, obj2): - """Raise a ValueError for incompatible object shapes.""" + """Raise a ValueError for incompatible object shapes. + + Parameters: + op (str): Name of the operation to appear in the error message. + obj1 (Qube): The left operand of the operation. + obj2 (Qube): The right operand of the operation. + + Raises: + ValueError: Always, quoting the shape of each operand. + """ opstr = obj1._opstr(op) raise ValueError(f'incompatible object shapes for {opstr}: ' @@ -104,7 +138,16 @@ def _raise_incompatible_shape(op, /, obj1, obj2): def _raise_incompatible_numers(op, /, obj1, obj2): - """Raise a ValueError for incompatible numerators in operation.""" + """Raise a ValueError for incompatible numerators in an operation. + + Parameters: + op (str): Name of the operation to appear in the error message. + obj1 (Qube): The left operand of the operation. + obj2 (Qube): The right operand of the operation. + + Raises: + ValueError: Always, quoting the numerator shape of each operand. + """ opstr = obj1._opstr(op) raise ValueError(f'incompatible numerator shapes for {opstr}: ' @@ -112,7 +155,16 @@ def _raise_incompatible_numers(op, /, obj1, obj2): def _raise_incompatible_denoms(op, /, obj1, obj2): - """Raise a ValueError for incompatible denominators in operation.""" + """Raise a ValueError for incompatible denominators in an operation. + + Parameters: + op (str): Name of the operation to appear in the error message. + obj1 (Qube): The left operand of the operation. + obj2 (Qube): The right operand of the operation. + + Raises: + ValueError: Always, quoting the denominator shape of each operand. + """ opstr = obj1._opstr(op) raise ValueError(f'incompatible denominator shapes for {opstr}: ' @@ -120,7 +172,16 @@ def _raise_incompatible_denoms(op, /, obj1, obj2): def _raise_dual_denoms(op, /, obj1, obj2): - """Raise a ValueError for denominators on both operands.""" + """Raise a ValueError for denominators on both operands. + + Parameters: + op (str): Name of the operation to appear in the error message. + obj1 (Qube): The left operand of the operation. + obj2 (Qube): The right operand of the operation. + + Raises: + ValueError: Always, because only one operand may have a denominator. + """ opstr = obj1._opstr(op) raise ValueError(f'only one operand of {opstr} can have a denominator') diff --git a/src/polymath/extensions/indexer.py b/src/polymath/extensions/indexer.py index 791aef5..28e3e29 100644 --- a/src/polymath/extensions/indexer.py +++ b/src/polymath/extensions/indexer.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/indexer.py: indexing operations ########################################################################################## +"""Indexing of PolyMath objects, supporting ``object[indx]`` and ``object[indx] = arg``. + +Indexing applies to the leading array axes of an object, leaving its items intact. Beyond +what NumPy accepts, an index may itself be a PolyMath object: a :class:`~polymath.Boolean` +selects where it is True, and a masked index selects nothing at the masked locations. +""" import numpy as np import numbers @@ -12,7 +18,7 @@ def __getitem__(self, indx): - """self[indx], returning the selected subset of this object. + """``self[indx]``, returning the selected subset of this object. Indexing follows NumPy's rules, applied to the leading shape only; the item axes are never indexed. It is extended in two ways: a masked index value selects a masked @@ -20,26 +26,25 @@ def __getitem__(self, indx): than raising. Parameters: - indx (object or tuple): The index, which may combine integers, slices, Ellipsis, - None, boolean arrays, integer arrays, and Scalar, Boolean or Vector objects. + indx (Any): The index, which may combine integers, slices, Ellipsis, None, boolean + arrays, integer arrays, and Scalar, Boolean or Vector objects. Returns: Qube: The selected subset, with the same subclass as this object. Derivatives are indexed the same way. Raises: - IndexError: If the index is malformed, has too many terms, or is - floating-point. + IndexError: If the index is malformed, has too many terms, or is floating-point. Notes: Two behaviors differ from NumPy deliberately: * Axes selected by array indices keep their position. NumPy moves them to the - front when the array indices are not consecutive, so ``a[:, [0,1], :, [0,1]]`` - has shape (2,4,6) in NumPy where here it has shape (4,2,6). + front when the array indices are not consecutive, so ``a[:, [0,1], :, [0,1]]`` + has shape (2,4,6) in NumPy where here it has shape (4,2,6). * A single boolean does not add a leading axis. ``a[True]`` has the shape of `a`, - where NumPy gives it shape (1,) + a.shape; ``a[False]`` gives a zero-sized - object either way. + where NumPy gives it shape (1,) + a.shape; ``a[False]`` gives a zero-sized + object either way. """ # Handle indexing of a shapeless object @@ -126,7 +131,7 @@ def __getitem__(self, indx): def __setitem__(self, indx, arg): - """self[indx] = arg, replacing the selected subset of this object. + """``self[indx] = arg``, replacing the selected subset of this object. The index is interpreted exactly as it is by :meth:`~Qube.__getitem__`, including the two departures from NumPy described there. Locations where the index itself is masked @@ -135,13 +140,12 @@ def __setitem__(self, indx, arg): derivative that this object has and `arg` does not is set to zero at those locations. Parameters: - indx (object or tuple): The index, interpreted as in __getitem__(). - arg (Qube, array-like, float, int, or bool): The replacement value, broadcastable - to the shape that the index selects. + indx (Any): The index, interpreted as in :meth:`~Qube.__getitem__`. + arg (QubeLike): The replacement value, broadcastable to the shape that the index + selects. Raises: - IndexError: If the index is malformed, has too many terms, or is - floating-point. + IndexError: If the index is malformed, has too many terms, or is floating-point. ValueError: If this object is read-only, or if `arg` cannot be broadcast to the selected shape. """ @@ -297,7 +301,7 @@ def _prep_index(self, indx): """Prepare the index for this object. Parameters: - indx (object or tuple): Index to prepare. + indx (Any): Index to prepare. Returns: tuple: A tuple containing (pre_index, post_mask, has_ellipsis, moved_to_front, @@ -568,7 +572,7 @@ def _prep_scalar_index(self, indx): None. Parameters: - indx (object or tuple): Index to prepare. + indx (Any): Index to prepare. Returns: tuple: A tuple containing (masked, size_zero, shape_before, shape_after) where: @@ -640,9 +644,9 @@ def _unused_index(index_vals, mask_vals, axis_length): element that the index also selects for real. Parameters: - index_vals (numpy.ndarray): Index values, already reduced to the range - [0, `axis_length`). - mask_vals (numpy.ndarray or bool): The mask on `index_vals`. At least one value + index_vals (numpy.ndarray): Index values, already reduced to the range [0, + `axis_length`). + mask_vals (numpy.ndarray | bool): The mask on `index_vals`. At least one value must be masked. axis_length (int): The length of the axis being indexed. diff --git a/src/polymath/extensions/item_ops.py b/src/polymath/extensions/item_ops.py index 323a6ab..99973e8 100644 --- a/src/polymath/extensions/item_ops.py +++ b/src/polymath/extensions/item_ops.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/item_ops.py: item restructuring operations ########################################################################################## +"""Restructuring of the item axes of a PolyMath object. + +The item axes of an object comprise its numerator followed by its denominator. These +functions extract, reshape, flatten, transpose, and recombine those axes, which is how a +derivative's denominator is manipulated and how an object is reinterpreted as a different +PolyMath class. +""" import math import numpy as np @@ -11,15 +18,15 @@ 'split_items', 'swap_items', 'transpose_denom', 'transpose_numer'] -def extract_numer(self, axis, index, classes=(), *, recursive=True): +def extract_numer(self, axis, index, *, classes=(), recursive=True): """Extract an object from one numerator axis. Parameters: axis (int): The item axis from which to extract a slice. index (int): The index value at which to extract the slice. - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include matching slices of the derivatives in the returned object; otherwise, the returned object will not contain derivatives. @@ -43,7 +50,7 @@ def extract_numer(self, axis, index, classes=(), *, recursive=True): # Construct and cast obj = Qube(new_values, self._mask, nrank=self._nrank-1, example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly # Slice the derivatives if necessary @@ -55,7 +62,7 @@ def extract_numer(self, axis, index, classes=(), *, recursive=True): return obj -def extract_denom(self, axis, index, classes=()): +def extract_denom(self, axis, index, *, classes=()): """Extract an object from one denominator axis. Extracting from a denominator axis removes that axis from the denominator and leaves @@ -66,13 +73,13 @@ def extract_denom(self, axis, index, classes=()): Parameters: axis (int): The item axis from which to extract a slice. index (int): The index value at which to extract the slice. - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. Returns: - Qube: An object extracted from the specified denominator axis. The shape is - reduced by removing the extracted axis dimension. + Qube: An object extracted from the specified denominator axis. Its denominator + lacks the extracted axis. Raises: ValueError: If the axis is out of range. @@ -91,22 +98,24 @@ def extract_denom(self, axis, index, classes=()): # Construct and cast obj = Qube(new_values, self._mask, drank=self._drank - 1, example=self) - obj = obj.cast((type(self),) + classes) + obj = obj.cast(classes=(type(self),) + classes) obj._readonly = self._readonly return obj def extract_denoms(self): - """A tuple of objects extracted from one object with a 1-D denominator. + """A list of objects extracted from one object with a 1-D denominator. - Returns a list of objects with the same class as self, but drank = 0. + Each returned object has the same class as this object but no denominator. An object + that has no denominator is returned as the only member of the list. Returns: - list: A list of objects with drank = 0. + list[Qube]: A list of objects with drank = 0, one for each element of the + denominator axis. Raises: - ValueError: If the object does not have a 1-D denominator. + ValueError: If the denominator of this object has more than one axis. """ if self._drank == 0: @@ -125,16 +134,16 @@ def extract_denoms(self): return objects -def slice_numer(self, axis, index1, index2, classes=(), *, recursive=True): +def slice_numer(self, axis, index1, index2, *, classes=(), recursive=True): """Extract an object sliced from one numerator axis. Parameters: axis (int): The item axis from which to extract a slice. index1 (int): The starting index value at which to extract the slice. index2 (int): The ending index value at which to extract the slice. - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include matching slices of the derivatives in the returned object; otherwise, the returned object will not contain derivatives. @@ -159,7 +168,7 @@ def slice_numer(self, axis, index1, index2, classes=(), *, recursive=True): # Construct and cast obj = Qube(new_values, self._mask, example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly # Slice the derivatives if necessary @@ -218,14 +227,14 @@ def transpose_numer(self, axis1=0, axis2=1, *, recursive=True): return obj -def reshape_numer(self, shape, classes=(), recursive=True): +def reshape_numer(self, shape, *, classes=(), recursive=True): """This object with a new shape for numerator items. Parameters: - shape (tuple): The new shape for numerator items. - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + shape (tuple[int, ...]): The new shape for numerator items. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to reshape the derivatives in the same way; otherwise, the returned object will not contain derivatives. @@ -248,33 +257,33 @@ def reshape_numer(self, shape, classes=(), recursive=True): # Construct and cast obj = Qube(new_values, self._mask, nrank=len(shape), example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly # Reshape the derivatives if necessary if recursive: for key, deriv in self._derivs.items(): - obj.insert_deriv(key, deriv.reshape_numer(shape, classes, False)) + obj.insert_deriv(key, deriv.reshape_numer(shape, classes=classes, + recursive=False)) return obj -def flatten_numer(self, classes=(), *, recursive=True): +def flatten_numer(self, *, classes=(), recursive=True): """This object with a new numerator shape such that nrank == 1. Parameters: - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. - recursive (bool, optional): True to include matching slices of the derivatives in - the returned object; otherwise, the returned object will not contain - derivatives. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. + recursive (bool, optional): True to flatten the derivatives in the same way; + otherwise, the returned object will not contain derivatives. Returns: Qube: The flattened object. """ - return self.reshape_numer((self.nsize,), classes, recursive=recursive) + return self.reshape_numer((self.nsize,), classes=classes, recursive=recursive) ########################################################################################## # Denominator shaping operations @@ -321,7 +330,7 @@ def reshape_denom(self, shape): """This object with a new shape for denominator items. Parameters: - shape (tuple): The new denominator shape. + shape (tuple[int, ...]): The new denominator shape. Returns: Qube: The reshaped object. @@ -351,6 +360,9 @@ def reshape_denom(self, shape): def flatten_denom(self): """This object with a new denominator shape such that drank == 1. + + Returns: + Qube: A shallow copy with the denominator axes flattened into one. """ return self.reshape_denom((self.dsize,)) @@ -359,15 +371,16 @@ def flatten_denom(self): # Numerator/denominator operations ########################################################################################## -def join_items(self, classes): +def join_items(self, *, classes=()): """The object with denominator axes joined to the numerator. Derivatives are removed. Parameters: - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. If the list is empty (the default) or no class is + suitable, a generic Qube object will be returned. Returns: Qube: The object with joined items. @@ -378,22 +391,23 @@ def join_items(self, classes): obj = Qube(self._values, self._mask, nrank=(self._nrank + self._drank), drank=0, example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly return obj -def split_items(self, nrank, classes): +def split_items(self, nrank, *, classes=()): """The object with numerator axes converted to denominator axes. Derivatives are removed. Parameters: nrank (int): Number of numerator axes to retain. - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. If the list is empty (the default) or no class is + suitable, a generic Qube object will be returned. Returns: Qube: The object with split items. @@ -401,21 +415,22 @@ def split_items(self, nrank, classes): obj = Qube(self._values, self._mask, nrank=nrank, drank=(self._rank - nrank), example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly return obj -def swap_items(self, classes): +def swap_items(self, *, classes=()): """A new object with the numerator and denominator axes exchanged. Derivatives are removed. Parameters: - classes (type, list, or tuple, optional): The class of the object returned. If - a list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. If the list is empty (the default) or no class is + suitable, a generic Qube object will be returned. Returns: Qube: The object with swapped items. @@ -427,7 +442,7 @@ def swap_items(self, classes): new_values = np.moveaxis(new_values, -self._drank-1, -1) obj = Qube(new_values, self._mask, nrank=self._drank, drank=self._nrank, example=self) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) obj._readonly = self._readonly return obj @@ -446,8 +461,8 @@ def chain(self, /, arg): Qube: The result of the chain multiplication. """ - left = self.flatten_denom().join_items(Qube) - right = arg.flatten_numer(Qube) + left = self.flatten_denom().join_items(classes=Qube) + right = arg.flatten_numer(classes=Qube) return Qube.dot(left, right, -1, 0, classes=[type(self)], recursive=False) diff --git a/src/polymath/extensions/iterator.py b/src/polymath/extensions/iterator.py index 53d0ca2..54e3aae 100644 --- a/src/polymath/extensions/iterator.py +++ b/src/polymath/extensions/iterator.py @@ -1,6 +1,12 @@ -################################################################################ +########################################################################################## # polymath/extensions/iterator.py: iterator over Qube objects -################################################################################ +########################################################################################## +"""Iteration over the elements of a PolyMath object. + +Iterating over an object walks its leading axis, yielding one object per index, in the +same way that iterating over a NumPy array does. The classes here also provide iteration +over every element of a multidimensional object, with or without the accompanying index. +""" import itertools import numpy as np @@ -15,7 +21,7 @@ class QubeIterator: similar to how NumPy arrays can be iterated. Attributes: - obj (list or Qube): The object to iterate over. + obj (list | Qube): The object to iterate over. stop (int): The number of elements to iterate through. index (int): The current iteration position. """ @@ -70,9 +76,10 @@ class QubeNDIterator: returning both the index tuple and the value at that index. Attributes: - obj (numpy.ndarray): The object to iterate over. - shape (tuple): The shape of the object. - iterator (iterator): The underlying iterator. + obj (numpy.ndarray | Qube): The object to iterate over. + shape (tuple[int, ...]): The shape of the object. + iterator (Iterator | None): The underlying iterator over index tuples; None until + iteration begins. """ def __init__(self, obj): @@ -105,7 +112,8 @@ def __next__(self): """The next item in the iteration. Returns: - tuple: A tuple containing (index_tuple, item_at_index). + tuple[tuple[int, ...], Qube]: A tuple containing the index tuple and the item + at that index. Raises: StopIteration: When iteration is complete. @@ -128,13 +136,13 @@ def __iter__(self): def ndenumerate(self): """Iterate across all axes of this object. - This method provides an iterator that returns tuples containing the index - and the corresponding item at that index. + This method provides an iterator that returns tuples containing the index and the + corresponding item at that index. Returns: - QubeNDIterator: An iterator yielding (index_tuple, item_at_index) pairs. + QubeNDIterator: An iterator yielding (index tuple, item) pairs. """ return QubeNDIterator(obj=self) -################################################################################ +########################################################################################## diff --git a/src/polymath/extensions/mask_ops.py b/src/polymath/extensions/mask_ops.py index 51763c3..25422cb 100644 --- a/src/polymath/extensions/mask_ops.py +++ b/src/polymath/extensions/mask_ops.py @@ -1,6 +1,13 @@ -######################################################################################### +########################################################################################## # polymath/extensions/mask_ops.py: masking operations -######################################################################################### +########################################################################################## +"""Operations that mask the elements of a PolyMath object by value. + +These functions return a copy of an object in which elements satisfying some condition +have been masked, and optionally replaced. Conditions include equality, the ordering +comparisons against a limit, and falling inside or outside a range. The clipping operation +is here as well, because it shares the same treatment of the endpoints. +""" import numbers @@ -19,13 +26,13 @@ def mask_where(self, mask, replace=None, *, remask=True, recursive=True): If the mask is empty, this object is returned unchanged. Parameters: - mask (array-like): The mask to apply as a boolean array. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + mask (BooleanLike): The mask to apply as a boolean array. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. recursive (bool, optional): True to include and mask the derivatives as well; False to exclude derivatives from the returned object. @@ -116,7 +123,7 @@ def _replace_where(self, replace_values, mask, *, remask, recursive): Parameters: self (Qube): The object to copy. - replace_values (numpy.ndarray, float, int, or bool): The values of one item to + replace_values (numpy.ndarray | float | int | bool): The values of one item to substitute, already cast to this object's data type. mask (numpy.ndarray): Boolean mask of the items to replace, already validated against the shape of this object and known to contain at least one True. @@ -154,12 +161,12 @@ def _replaced_mask(old_mask, mask, remask): """The mask of an object after unmasked values have been substituted into it. Parameters: - old_mask (numpy.ndarray or bool): The mask before the substitution. + old_mask (numpy.ndarray | bool): The mask before the substitution. mask (numpy.ndarray): Boolean mask of the items that were replaced. remask (bool): True to mask the replaced items; False to unmask them. Returns: - (numpy.ndarray or bool): The mask after the substitution. + numpy.ndarray | bool: The mask after the substitution. """ if remask: @@ -174,18 +181,17 @@ def _replaced_mask(old_mask, mask, remask): def mask_where_eq(self, match, replace=None, *, remask=True): """A copy of this object with items equal to a value masked. - Instead of or in addition to masking the items, the values can be - replaced. If no items need to be masked, this object is returned - unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - match (object): The item value to match. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + match (Any): The item value to match. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with matching items masked. @@ -198,17 +204,17 @@ def mask_where_eq(self, match, replace=None, *, remask=True): def mask_where_ne(self, match, replace=None, *, remask=True): """A copy of this object with items not equal to a value masked. - Instead of or in addition to masking the items, the values can be replaced. - If no items need to be masked, this object is returned unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - match (object): The item value to match. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + match (Any): The item value to match. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with non-matching items masked. @@ -225,14 +231,14 @@ def _mask_where_match(self, match, comparison): Parameters: self (Qube): The object whose items are to be compared. - match (Qube, array-like, float, int, or bool): The item value to match. A value - that is not already an object of this class is converted to one, which - coerces it to this object's data type. - comparison (function): The NumPy comparison to apply, one of numpy.equal or + match (QubeLike): The item value to match. A value that is not already an object + of this class is converted to one, which coerces it to this object's data + type. + comparison (Callable): The NumPy comparison to apply, one of numpy.equal or numpy.not_equal. Returns: - (numpy.ndarray or bool): True for each item that satisfies the comparison. + numpy.ndarray | bool: True for each item that satisfies the comparison. """ # An object whose items are single elements can compare directly against a number @@ -255,17 +261,17 @@ def _mask_where_match(self, match, comparison): def mask_where_le(self, limit, replace=None, *, remask=True): """A copy of this object with items <= a limit value masked. - Instead of or in addition to masking the items, the values can be replaced. - If no items need to be masked, this object is returned unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - limit (object): The limiting value. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + limit (Any): The limiting value. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with items <= limit masked. @@ -283,17 +289,17 @@ def mask_where_le(self, limit, replace=None, *, remask=True): def mask_where_ge(self, limit, replace=None, *, remask=True): """A copy of this object with items >= a limit value masked. - Instead of or in addition to masking the items, the values can be replaced. - If no items need to be masked, this object is returned unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - limit (object): The limiting value. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + limit (Any): The limiting value. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with items >= limit masked. @@ -309,20 +315,19 @@ def mask_where_ge(self, limit, replace=None, *, remask=True): def mask_where_lt(self, limit, replace=None, *, remask=True): - """A copy with items less than a limit value masked. + """A copy of this object with items < a limit value masked. - Instead of or in addition to masking the items, the values can be - replaced. If no items need to be masked, this object is returned - unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - limit (object): The limiting value. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + limit (Any): The limiting value. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with items < limit masked. @@ -338,19 +343,19 @@ def mask_where_lt(self, limit, replace=None, *, remask=True): def mask_where_gt(self, limit, replace=None, *, remask=True): - """A copy with items greater than a limit value masked. + """A copy of this object with items > a limit value masked. - Instead of or in addition to masking the items, the values can be replaced. - If no items need to be masked, this object is returned unchanged. + Instead of or in addition to masking the items, the values can be replaced. If no + items need to be masked, this object is returned unchanged. Parameters: - limit (object): The limiting value. - replace (object, optional): A single replacement value or an object of the same - shape and class as this object, containing replacement values. These are - inserted into returned object at every masked location. Use None to leave + limit (Any): The limiting value. + replace (Any, optional): A single replacement value or an object of the + same shape and class as this object, containing replacement values. These are + inserted into the returned object at every masked location. Use None to leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy of this object with items > limit masked. @@ -373,21 +378,20 @@ def mask_where_between(self, lower, upper, *, mask_endpoints=False, replace=None items need to be masked, this object is returned unchanged. Parameters: - lower (Qube, array-like, float, or int): The lower limit as a number or an object - that can be broadcasted to the shape of this object's values (including its - item shape). Masked limits are ignored. - upper (Qube, array-like, float, or int): The upper limit as a number or an object - that can be broadcasted to the shape of this object's values (including its - item shape). Masked limits are ignored. - mask_endpoints (bool or tuple, optional): True to mask the endpoints, where values - are equal to the lower or upper limits; False to exclude the endpoints. Use a - tuple of two values to handle the endpoints differently. - replace (Qube, array-like, float, or int, optional): A single replacement value or - an object that can be broadcasted to the shape of this object's values - (including its item shape). Masked replacements become masked. Use None to - leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + lower (QubeLike): The lower limit as a number or an object that can be broadcasted + to the shape of this object's values (including its item shape). Masked limits + are ignored. + upper (QubeLike): The upper limit as a number or an object that can be broadcasted + to the shape of this object's values (including its item shape). Masked limits + are ignored. + mask_endpoints (bool | tuple[bool, bool], optional): True to mask the endpoints, + where values are equal to the lower or upper limits; False to exclude the + endpoints. Use a tuple of two values to handle the endpoints differently. + replace (QubeLike | None, optional): A single replacement value or an object that + can be broadcasted to the shape of this object's values (including its item + shape). Masked replacements become masked. Use None to leave values unchanged. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy with values between the specified limits masked. @@ -428,21 +432,20 @@ def mask_where_outside(self, lower, upper, *, mask_endpoints=False, replace=None items need to be masked, this object is returned unchanged. Parameters: - lower (Qube, array-like, float, or int): The lower limit as a number or an object - that can be broadcasted to the shape of this object's values (including its - item shape). Masked limits are ignored. - upper (Qube, array-like, float, or int): The upper limit as a number or an object - that can be broadcasted to the shape of this object's values (including its - item shape). Masked limits are ignored. - mask_endpoints (bool or tuple, optional): True to mask the endpoints, where values - are equal to the lower or upper limits; False to exclude the endpoints. Use a - tuple of two values to handle the endpoints differently. - replace (Qube, array-like, float, or int, optional): A single replacement value or - an object that can be broadcasted to the shape of this object's values - (including its item shape). Masked replacements become masked. Use None to - leave values unchanged. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + lower (QubeLike): The lower limit as a number or an object that can be broadcasted + to the shape of this object's values (including its item shape). Masked limits + are ignored. + upper (QubeLike): The upper limit as a number or an object that can be broadcasted + to the shape of this object's values (including its item shape). Masked limits + are ignored. + mask_endpoints (bool | tuple[bool, bool], optional): True to mask the endpoints, + where values are equal to the lower or upper limits; False to exclude the + endpoints. Use a tuple of two values to handle the endpoints differently. + replace (QubeLike | None, optional): A single replacement value or an object that + can be broadcasted to the shape of this object's values (including its item + shape). Masked replacements become masked. Use None to leave values unchanged. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. Returns: Qube: A copy with values outside the specified limits masked. @@ -478,16 +481,16 @@ def mask_where_outside(self, lower, upper, *, mask_endpoints=False, replace=None def clip(self, lower, upper, *, remask=True, inclusive=True): """A copy with values clipped to fall within a pair of limits. - Values below the lower limit become equal to the lower limit; values above - the upper limit become equal to the upper limit. + Values below the lower limit become equal to the lower limit; values above the upper + limit become equal to the upper limit. Parameters: - lower (object, optional): The numerical lower limit or an object of the same shape - and type as this, containing lower limits. None or masked values to ignore. - upper (object, optional): The numerical upper limit or an object of the same shape - and type as this, containing upper limits. None or masked values to ignore. - remask (bool, optional): True to leave the new values masked; False to replace - the values but leave them unmasked. + lower (Any): The numerical lower limit or an object of the same shape and type as + this, containing lower limits. None or masked values are ignored. + upper (Any): The numerical upper limit or an object of the same shape and type as + this, containing upper limits. None or masked values are ignored. + remask (bool, optional): True to leave the new values masked; False to replace the + values but leave them unmasked. inclusive (bool, optional): True to leave values that exactly match the upper limit unmasked; False to mask them. @@ -505,7 +508,7 @@ def clip(self, lower, upper, *, remask=True, inclusive=True): if np.isscalar(lower) and np.isscalar(upper): new_values = np.clip(self._values, lower, upper) if remask: - outside = Qube.is_outside(self._values, lower, upper, inclusive) + outside = Qube.is_outside(self._values, lower, upper, inclusive=inclusive) mask = Qube.or_(self._mask, outside) else: mask = self._mask @@ -513,7 +516,7 @@ def clip(self, lower, upper, *, remask=True, inclusive=True): # Without remasking, derivatives out of range are now all zero if self._derivs and not remask: new_derivs = {} - outside = Qube.is_outside(self._values, lower, upper, inclusive) + outside = Qube.is_outside(self._values, lower, upper, inclusive=inclusive) for key, deriv in self._derivs.items(): new_deriv = deriv.copy() new_deriv[outside] = deriv.zero() @@ -541,23 +544,24 @@ def clip(self, lower, upper, *, remask=True, inclusive=True): return result -def _limit_from_qube(self, limit, masked, op): - """Interpret the limit. +def _limit_from_qube(self, limit, *, masked, op): + """Interpret a limit as an array or number to compare against this object's values. Parameters: - self (Qube): The object for which the limit is to be applied. - limit (Qube, array-like, float, or int): Limit value. If it is array-like or a - Qube, it must be broadcastable to self.shape. Also, if it is a Qube, the shape - of its numerator must be either () or self.numer. - masked (Qube, array-like, float or int): The value(s) to use where `limit` is - masked if it is a Qube. If it is array-like or a Qube, it must be - broadcastable to self.shape. Also, if it is a Qube, the shape of its numerator - must be either () or self.numer. - op (str, optional): Operation name to appear in an error message. + self (Qube): The object to which the limit is to be applied. + limit (QubeLike): Limit value. If it is an array or a Qube, it must be + broadcastable to `self.shape`. Also, if it is a Qube, the shape of its + numerator must be either () or `self.numer`. + masked (float): The value to use wherever `limit` is a masked Qube. + op (str): Operation name to appear in an error message. Returns: - (numpy.ndarray, float, or int): The value of `limit` as an array or scalar. If it - is an array, its shape will match that of the array `self.values`. + numpy.ndarray | float | int: The value of `limit` as an array or scalar. If it is + an array, its shape will match that of the array `self.values`. + + Raises: + ValueError: If `limit` has denominators or its numerator shape does not match that + of this object. """ if isinstance(limit, np.ndarray): @@ -605,22 +609,22 @@ def _limit_from_qube(self, limit, masked, op): vals[mask] = masked return vals -######################################################################################### +########################################################################################## # Convenience methods for range masks and clipping -######################################################################################### +########################################################################################## @staticmethod -def is_below(arg, high, inclusive=True): - """Check if arg is inside a range with upper end at high. +def is_below(arg, high, *, inclusive=True): + """Check if `arg` is inside a range with upper end at high. Parameters: - arg (object): The value to check. - high (object): The upper limit of the range. + arg (Any): The value to check. + high (Any): The upper limit of the range. inclusive (bool, optional): True to include the upper limit in the range; False to exclude it. Returns: - bool: True if arg is inside the range with upper end at high. + MaskType: True if `arg` is inside the range with upper end at high. """ if inclusive: @@ -630,17 +634,17 @@ def is_below(arg, high, inclusive=True): @staticmethod -def is_above(arg, high, inclusive=True): - """Check if arg is outside a range with upper end at high. +def is_above(arg, high, *, inclusive=True): + """Check if `arg` is outside a range with upper end at high. Parameters: - arg (object): The value to check. - high (object): The upper limit of the range. + arg (Any): The value to check. + high (Any): The upper limit of the range. inclusive (bool, optional): True to include the upper limit in the range; False to exclude it. Returns: - bool: True if arg is outside the range with upper end at high. + MaskType: True if `arg` is outside the range with upper end at high. """ if inclusive: @@ -650,18 +654,18 @@ def is_above(arg, high, inclusive=True): @staticmethod -def is_outside(arg, low, high, inclusive=True): - """Check if arg is outside the range low to high. +def is_outside(arg, low, high, *, inclusive=True): + """Check if `arg` is outside the range low to high. Parameters: - arg (object): The value to check. - low (object): The lower limit of the range. - high (object): The upper limit of the range. + arg (Any): The value to check. + low (Any): The lower limit of the range. + high (Any): The upper limit of the range. inclusive (bool, optional): True to include the upper limit in the range; False to exclude it. Returns: - bool: True if arg is outside the range low to high. + MaskType: True if `arg` is outside the range low to high. """ if inclusive: @@ -671,18 +675,18 @@ def is_outside(arg, low, high, inclusive=True): @staticmethod -def is_inside(arg, low, high, inclusive=True): - """Check if arg is inside the range low to high. +def is_inside(arg, low, high, *, inclusive=True): + """Check if `arg` is inside the range low to high. Parameters: - arg (object): The value to check. - low (object): The lower limit of the range. - high (object): The upper limit of the range. + arg (Any): The value to check. + low (Any): The lower limit of the range. + high (Any): The upper limit of the range. inclusive (bool, optional): True to include the upper limit in the range; False to exclude it. Returns: - bool: True if arg is inside the range low to high. + MaskType: True if `arg` is inside the range low to high. """ if inclusive: @@ -690,4 +694,4 @@ def is_inside(arg, low, high, inclusive=True): else: return (arg >= low) & (arg < high) -######################################################################################### +########################################################################################## diff --git a/src/polymath/extensions/masking.py b/src/polymath/extensions/masking.py index f92ef8e..47bdbd8 100644 --- a/src/polymath/extensions/masking.py +++ b/src/polymath/extensions/masking.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/masking.py: Mask construction and object mask operations ########################################################################################## +"""Construction of masks and operations on the mask of a PolyMath object. + +A mask is a boolean array, or a single boolean applying to every element, in which True +marks a value as invalid. These functions convert an arbitrary argument into a mask of a +suitable shape, combine masks, count the masked and unmasked elements, and return copies +of an object whose mask has been replaced or removed. +""" import numpy as np import numbers @@ -19,17 +26,17 @@ @staticmethod def _as_mask(arg, *, invert=False, masked_value=True, opstr=''): - """This argument converted to a scalar bool or boolean Numpy array. + """This argument converted to a scalar bool or boolean NumPy array. Parameters: - arg: The object to convert to a mask. + arg (BooleanLike): The object to convert to a mask. invert (bool, optional): True to return the logical not of the mask. masked_value (bool, optional): The value to use where the input argument is - masked. This value is used _after_ `invert` is applied. + masked. This value is used *after* `invert` is applied. opstr (str, optional): Name of operation to include in any error message. Returns: - (bool or NumPy.ndarray): bool or boolean array suitable for us as a mask. + MaskType: A bool or boolean array suitable for use as a mask. Raises: TypeError: If the data type of `arg` is invalid for a mask. @@ -90,25 +97,24 @@ def _as_mask(arg, *, invert=False, masked_value=True, opstr=''): @staticmethod def _suitable_mask(arg, shape, *, collapse=False, broadcast=False, invert=False, masked_value=True, check=False, opstr=''): - """This argument converted to a scalar bool or boolean Numpy array of suitable - shape to use as a mask. + """This argument converted to a bool or boolean NumPy array shaped to serve as a mask. Parameters: - arg: The object to convert to a mask. - shape (tuple): Shape of the required mask. - collapse (bool, optional): True to merge the extraneous axes of a mask if its - rank is greater than that of the given shape. - broadcast (bool, optional): True to broadcast this mask if its rank is less - than that of the given shape. + arg (BooleanLike): The object to convert to a mask. + shape (tuple[int, ...]): Shape of the required mask. + collapse (bool, optional): True to merge the extraneous axes of a mask if its rank + is greater than that of the given shape. + broadcast (bool, optional): True to broadcast this mask if its rank is less than + that of the given shape. invert (bool, optional): True to return the logical not of the mask. masked_value (bool, optional): The value to use where the input argument is - nmasked. This value is used _after_ `invert` is applied. - check (bool, optional): True to check for an array containing all False - values, and if so, replace it with a single value of False. + masked. This value is used *after* `invert` is applied. + check (bool, optional): True to check for an array containing all False values, + and if so, replace it with a single value of False. opstr (str, optional): Name of operation to include in any error message. Returns: - (bool or NumPy.ndarray): bool or boolean mask array. + MaskType: A bool or boolean mask array. Raises: TypeError: If the data type of `arg` is invalid for a mask. @@ -154,13 +160,13 @@ def _suitable_mask(arg, shape, *, collapse=False, broadcast=False, invert=False, @staticmethod def or_(*masks): - """The logical "or" of two or more masks, avoiding array operations if possible. + """The logical "or" of one or more masks, avoiding array operations if possible. Parameters: - *masks (array-like or bool): One or more boolean masks. + *masks (MaskType): One or more masks, each a bool or a boolean array. Returns: - (numpy.ndarray or bool): New mask array or bool. + MaskType: The combined mask as a new array or a bool. """ # Two inputs is most common @@ -211,13 +217,13 @@ def or_(*masks): @staticmethod def and_(*masks): - """The logical "and" of two or more masks, avoiding array operations if possible. + """The logical "and" of one or more masks, avoiding array operations if possible. Parameters: - *masks (array-like or bool): One or more boolean masks. + *masks (MaskType): One or more masks, each a bool or a boolean array. Returns: - (numpy.ndarray or bool): New mask array or bool. + MaskType: The combined mask as a new array or a bool. """ # Two inputs is most common @@ -271,13 +277,21 @@ def and_(*masks): def is_all_masked(self): - """True if this object is entirely masked.""" + """True if this object is entirely masked. + + Returns: + bool: True if every element of this object is masked. + """ return np.all(self._mask) def count_masked(self): - """The number of masked items in this object.""" + """The number of masked items in this object. + + Returns: + int: The number of masked elements. + """ if isinstance(self._mask, np.ndarray): return np.sum(self._mask) @@ -286,7 +300,11 @@ def count_masked(self): def count_unmasked(self): - """The number of unmasked items in this object.""" + """The number of unmasked items in this object. + + Returns: + int: The number of unmasked elements. + """ if isinstance(self._mask, np.ndarray): return self._size - np.sum(self._mask) @@ -295,7 +313,15 @@ def count_unmasked(self): def masked_single(self, *, recursive=True): - """An object of this subclass containing one masked value.""" + """An object of this subclass containing one masked value. + + Parameters: + recursive (bool, optional): True to include masked derivatives of the same names + as this object's derivatives. + + Returns: + Qube: A shapeless, read-only, fully masked object. + """ if not self._rank: new_value = self._default @@ -314,8 +340,9 @@ def masked_single(self, *, recursive=True): def without_mask(self, *, recursive=True): - """A shallow copy of this object without its mask. Note that masked values will be - revealed. + """A shallow copy of this object without its mask. + + Note that masked values will be revealed. Parameters: recursive (bool, optional): True to unmask any derivatives; False to strip @@ -364,23 +391,23 @@ def as_one_masked(self, *, recursive=True): derivatives. Returns: - Qube: This object but fully masked and with shape () + Qube: This object but fully masked and with shape (). """ - return self.flatten()[0].as_all_masked() + return self.flatten()[0].as_all_masked(recursive=recursive) def remask(self, mask, *, recursive=True, check=True): """A shallow copy of this object with a replaced mask. - This is much quicker than masked_where(), for cases where only the mask of this - object is changing. + This is much quicker than :meth:`~polymath.Qube.mask_where`, for cases where only the + mask of this object is changing. Parameters: - mask (array-like or bool): The new mask to be applied to the object. + mask (BooleanLike): The new mask to be applied to the object. recursive (bool, optional): True to apply the same mask to any derivatives. - check (bool, optional): True to check for an array containing all False - values, and if so, replace it with a single value of False. + check (bool, optional): True to check for an array containing all False values, + and if so, replace it with a single value of False. Returns: Qube: A shallow copy of this object with a new mask. @@ -404,17 +431,16 @@ def remask(self, mask, *, recursive=True, check=True): def remask_or(self, mask, *, recursive=True, check=True): - """A shallow copy of this object, in which the current mask is "or-ed" with the - given mask. + """A shallow copy of this object in which the given mask is "or-ed" into its mask. - This is much quicker than masked_where(), for cases where only the mask is - changing. + This is much quicker than :meth:`~polymath.Qube.mask_where`, for cases where only the + mask of this object is changing. Parameters: - mask (array-like or bool): The new mask to be applied to the object. + mask (BooleanLike): The new mask to be applied to the object. recursive (bool, optional): True to apply the same mask to any derivatives. - check (bool, optional): True to check for an array containing all False - values, and if so, replace it with a single value of False. + check (bool, optional): True to check for an array containing all False values, + and if so, replace it with a single value of False. Returns: Qube: A shallow copy of this object with a new mask. @@ -432,14 +458,13 @@ def remask_or(self, mask, *, recursive=True, check=True): if recursive: for key, deriv in self._derivs.items(): - obj.insert_deriv(key, deriv.remask(mask, recursive=False, check=False)) + obj.insert_deriv(key, deriv.remask_or(mask, recursive=False, check=False)) return obj def expand_mask(self, *, recursive=True): - """A shallow copy where a single mask value of True or False is converted to an - array. + """A shallow copy in which a single mask value of True or False becomes an array. If the object's mask is already an array, it is returned unchanged. @@ -486,8 +511,7 @@ def expand_mask(self, *, recursive=True): def collapse_mask(self, *, recursive=True): - """A shallow copy where a mask entirely containing either True or False is - converted to a single boolean. + """A shallow copy in which an all-True or all-False mask array becomes a single bool. Parameters: recursive (bool, optional): True to collapse the mask of any derivatives. @@ -533,25 +557,41 @@ def collapse_mask(self, *, recursive=True): def as_mask_where_nonzero(self): - """A boolean scalar or NumPy ndarray where values are nonzero and unmasked.""" + """A boolean scalar or NumPy ndarray where values are nonzero and unmasked. + + Returns: + MaskType: True where an element is nonzero and unmasked. + """ return (self._values != 0) & self.antimask def as_mask_where_zero(self): - """A boolean scalar or NumPy ndarray where values are zero and unmasked.""" + """A boolean scalar or NumPy ndarray where values are zero and unmasked. + + Returns: + MaskType: True where an element is zero and unmasked. + """ return (self._values == 0) & self.antimask def as_mask_where_nonzero_or_masked(self): - """A boolean scalar or NumPy ndarray where values are nonzero or masked.""" + """A boolean scalar or NumPy ndarray where values are nonzero or masked. + + Returns: + MaskType: True where an element is nonzero or masked. + """ return (self._values != 0) | self._mask def as_mask_where_zero_or_masked(self): - """A boolean scalar or NumPy ndarray where values are zero or masked.""" + """A boolean scalar or NumPy ndarray where values are zero or masked. + + Returns: + MaskType: True where an element is zero or masked. + """ return (self._values == 0) | self._mask diff --git a/src/polymath/extensions/math_ops.py b/src/polymath/extensions/math_ops.py index 5e6a6cd..9af0366 100644 --- a/src/polymath/extensions/math_ops.py +++ b/src/polymath/extensions/math_ops.py @@ -1,6 +1,14 @@ ########################################################################################## # polymath/extensions/math_ops.py: Math operations ########################################################################################## +"""The arithmetic and logical operators of a PolyMath object. + +This module defines the unary and binary operators, their in-place and reflected forms, +the comparison operators, and the reductions such as +:func:`~polymath.extensions.math_ops.sum` and :func:`~polymath.extensions.math_ops.mean`. +Operations propagate units and derivatives where that is meaningful, and combine the masks +of their operands. +""" import numpy as np import numbers @@ -17,7 +25,7 @@ ########################################################################################## def __pos__(self, *, recursive=True): - """+self, element by element. + """``+self``, element by element. Parameters: recursive (bool, optional): True to include derivatives in return. @@ -30,7 +38,7 @@ def __pos__(self, *, recursive=True): def __neg__(self, *, recursive=True): - """-self, element-by-element negation. + """``-self``, element-by-element negation. Parameters: recursive (bool, optional): True to include derivatives in return. @@ -52,7 +60,7 @@ def __neg__(self, *, recursive=True): def __abs__(self, *, recursive=True): - """abs(self), element-by-element absolute value. + """``abs(self)``, element-by-element absolute value. Parameters: recursive (bool, optional): True to include derivatives in return. @@ -65,12 +73,23 @@ def __abs__(self, *, recursive=True): def abs(self): - """abs(self), element-by-element absolute value.""" + """``abs(self)``, element-by-element absolute value. + + Returns: + Qube: The element-by-element absolute value. + """ return self.__abs__() def __len__(self): - """Number of elements along first axis.""" + """``len(self)``, the number of elements along the first axis. + + Returns: + int: The length of the leading axis. + + Raises: + TypeError: If this object has no leading axis. + """ if self._ndims: return self._shape[0] @@ -78,7 +97,14 @@ def __len__(self): raise TypeError(f'len of unsized {type(self).__name__} object') def len(self): - """Number of elements along first axis.""" + """Number of elements along the first axis. + + Returns: + int: The length of the leading axis. + + Raises: + TypeError: If this object has no leading axis. + """ return self.__len__() @@ -87,13 +113,12 @@ def len(self): ########################################################################################## def __add__(self, /, arg, *, recursive=True): - """self + arg, element-by-element addition. + """``self + arg``, element-by-element addition. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Qube of the same type as self using as_this_type(). - For simple scalar operations (when self._rank == 0), Python numbers are - handled directly for efficiency. + arg (QubeLike): The argument. If not a Qube object, it will be converted to a Qube + of the same type as self using as_this_type(). For simple scalar operations + (when self._rank == 0), Python numbers are handled directly for efficiency. recursive (bool, optional): True to include derivatives in return. Returns: @@ -139,11 +164,11 @@ def __add__(self, /, arg, *, recursive=True): def __radd__(self, /, arg, *, recursive=True): - """arg + self, element-by-element addition. + """``arg + self``, element-by-element addition. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Qube of the same type as self using as_this_type(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a Qube + of the same type as self using as_this_type(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -153,13 +178,17 @@ def __radd__(self, /, arg, *, recursive=True): return self.__add__(arg, recursive=recursive) def __iadd__(self, /, arg): - """self += arg, element-by-element in-place addition. + """``self += arg``, element-by-element in-place addition. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. + arg (QubeLike): The argument. Returns: Qube: self after the addition. + + Raises: + ValueError: If this object is read-only. + TypeError: If this object holds integers but the result does not. """ self.require_writeable() @@ -205,7 +234,15 @@ def __iadd__(self, /, arg): def _add_derivs(self, /, arg1, arg2): - """Dictionary of added derivatives.""" + """Dictionary of added derivatives. + + Parameters: + arg1 (Qube): The left operand of the addition. + arg2 (Qube): The right operand of the addition. + + Returns: + dict[str, Qube]: The derivatives of the sum, keyed by name. + """ set1 = set(arg1._derivs.keys()) set2 = set(arg2._derivs.keys()) @@ -228,13 +265,12 @@ def _add_derivs(self, /, arg1, arg2): ########################################################################################## def __sub__(self, /, arg, *, recursive=True): - """self - arg, element-by-element subtraction. + """``self - arg``, element-by-element subtraction. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Qube of the same type as self using as_this_type(). - For simple scalar operations (when self._rank == 0), Python numbers are - handled directly for efficiency. + arg (QubeLike): The argument. If not a Qube object, it will be converted to a Qube + of the same type as self using as_this_type(). For simple scalar operations + (when self._rank == 0), Python numbers are handled directly for efficiency. recursive (bool, optional): True to include derivatives in return. Returns: @@ -280,11 +316,11 @@ def __sub__(self, /, arg, *, recursive=True): def __rsub__(self, /, arg, *, recursive=True): - """arg - self, element-by-element subtraction. + """``arg - self``, element-by-element subtraction. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Qube of the same type as self using as_this_type(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a Qube + of the same type as self using as_this_type(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -300,14 +336,18 @@ def __rsub__(self, /, arg, *, recursive=True): def __isub__(self, /, arg): - """self -= arg, element-by-element in-place subtraction. + """``self -= arg``, element-by-element in-place subtraction. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Qube of the same type as self using as_this_type(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a Qube + of the same type as self using as_this_type(). Returns: Qube: self after the subtraction. + + Raises: + ValueError: If this object is read-only. + TypeError: If this object holds integers but the result does not. """ self.require_writeable() @@ -353,7 +393,15 @@ def __isub__(self, /, arg): def _sub_derivs(self, /, arg1, arg2): - """Dictionary of subtracted derivatives.""" + """Dictionary of subtracted derivatives. + + Parameters: + arg1 (Qube): The left operand of the subtraction. + arg2 (Qube): The right operand of the subtraction. + + Returns: + dict[str, Qube]: The derivatives of the difference, keyed by name. + """ set1 = set(arg1._derivs.keys()) set2 = set(arg2._derivs.keys()) @@ -376,13 +424,12 @@ def _sub_derivs(self, /, arg1, arg2): ########################################################################################## def __mul__(self, /, arg, *, recursive=True): - """self * arg, element-by-element multiplication. + """``self * arg``, element-by-element multiplication. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). - For simple scalar operations (when self._rank == 0), Python numbers are - handled directly for efficiency. + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). For simple scalar operations (when + self._rank == 0), Python numbers are handled directly for efficiency. recursive (bool, optional): True to include derivatives in return. Returns: @@ -430,11 +477,11 @@ def __mul__(self, /, arg, *, recursive=True): def __rmul__(self, /, arg, *, recursive=True): - """arg * self, element-by-element multiplication. + """``arg * self``, element-by-element multiplication. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -459,14 +506,18 @@ def __rmul__(self, /, arg, *, recursive=True): def __imul__(self, /, arg): - """Element-by-element in-place multiplication. + """``self *= arg``, element-by-element in-place multiplication. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: self after the multiplication. + + Raises: + ValueError: If this object is read-only. + TypeError: If this object holds integers but the result does not. """ self.require_writeable() @@ -523,7 +574,15 @@ def __imul__(self, /, arg): def _mul_by_number(self, /, arg, *, recursive=True): - """Internal multiply op when the arg is a Python scalar.""" + """Internal multiply op when the arg is a Python scalar. + + Parameters: + arg (float | int | bool): The number by which to multiply this object. + recursive (bool, optional): True to multiply the derivatives as well. + + Returns: + Qube: The product. + """ obj = self._clone_new_values(recursive=False, retain_cache=True) obj._set_values(self._values * arg, retain_cache=True) @@ -536,8 +595,16 @@ def _mul_by_number(self, /, arg, *, recursive=True): def _mul_by_scalar(self, /, arg, *, recursive=True): - """Internal multiply op when the arg is a Qube with nrank == 0 and no - more than one object has a denominator.""" + """Internal multiply op when `arg` is a Qube with ``nrank == 0`` and no more than one + object has a denominator. + + Parameters: + arg (Qube): The Scalar by which to multiply this object. + recursive (bool, optional): True to multiply the derivatives as well. + + Returns: + Qube: The product. + """ # Align axes self_values = self._values @@ -563,7 +630,14 @@ def _mul_by_scalar(self, /, arg, *, recursive=True): def _mul_derivs(self, /, arg): - """Dictionary of multiplied derivatives.""" + """Dictionary of multiplied derivatives. + + Parameters: + arg (Qube): The right operand of the multiplication. + + Returns: + dict[str, Qube]: The derivatives of the product, keyed by name. + """ new_derivs = {} @@ -587,15 +661,14 @@ def _mul_derivs(self, /, arg): ########################################################################################## def __truediv__(self, /, arg, *, recursive=True): - """self / arg, element-by-element division. + """``self / arg``, element-by-element division. Cases of divide-by-zero are masked. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). - For simple scalar operations (when self._rank == 0), Python numbers are - handled directly for efficiency. + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). For simple scalar operations (when + self._rank == 0), Python numbers are handled directly for efficiency. recursive (bool, optional): True to include derivatives in return. Returns: @@ -644,13 +717,13 @@ def __truediv__(self, /, arg, *, recursive=True): def __rtruediv__(self, /, arg, *, recursive=True): - """arg / self, element-by-element division. + """``arg / self``, element-by-element division. Cases of divide-by-zero are masked. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -675,16 +748,20 @@ def __rtruediv__(self, /, arg, *, recursive=True): # Generic in-place division def __itruediv__(self, /, arg): - """self /= arg, element-by-element in-place division. + """``self /= arg``, element-by-element in-place division. Cases of divide-by-zero are masked. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: self after the division. + + Raises: + TypeError: If this object holds integers. + ValueError: If this object is read-only. """ if not self.is_float(): @@ -723,7 +800,15 @@ def __itruediv__(self, /, arg): def _div_by_number(self, /, arg, *, recursive=True): - """Internal division op when the arg is a Python scalar.""" + """Internal division op when the arg is a Python scalar. + + Parameters: + arg (float | int | bool): The number by which to divide this object. + recursive (bool, optional): True to divide the derivatives as well. + + Returns: + Qube: The quotient. + """ obj = self._clone_new_values(recursive=False, retain_cache=True) @@ -741,7 +826,15 @@ def _div_by_number(self, /, arg, *, recursive=True): def _div_by_scalar(self, /, arg, *, recursive): - """Internal division op when the arg is a Qube with rank == 0.""" + """Internal division op when the arg is a Qube with ``rank == 0``. + + Parameters: + arg (Qube): The Scalar by which to divide this object. + recursive (bool): True to divide the derivatives as well. + + Returns: + Qube: The quotient. + """ # Mask out zeros arg = arg.mask_where_eq(0., 1.) @@ -767,8 +860,16 @@ def _div_by_scalar(self, /, arg, *, recursive): def _div_derivs(self, /, arg, *, nozeros=False): """Dictionary of divided derivatives. - If nozeros is True, the arg is assumed not to contain any zeros, so divide-by-zero + If `nozeros` is True, the arg is assumed not to contain any zeros, so divide-by-zero errors are not checked. + + Parameters: + arg (Qube): The right operand of the division. + nozeros (bool, optional): True if `arg` is known to contain no zeros, in which + case divide-by-zero errors are not checked. + + Returns: + dict[str, Qube]: The derivatives of the quotient, keyed by name. """ new_derivs = {} @@ -800,13 +901,13 @@ def _div_derivs(self, /, arg, *, nozeros=False): ########################################################################################## def __floordiv__(self, /, arg): - """self // arg, element-by-element floor division. + """``self // arg``, element-by-element floor division. Cases of divide-by-zero are masked. Derivatives are ignored. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: The result of the floor division. @@ -846,13 +947,13 @@ def __floordiv__(self, /, arg): # Generic right floor division def __rfloordiv__(self, /, arg): - """arg // self, element-by-element floor division. + """``arg // self``, element-by-element floor division. Cases of divide-by-zero are masked. Derivatives are ignored. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: The result of the floor division. @@ -872,16 +973,19 @@ def __rfloordiv__(self, /, arg): def __ifloordiv__(self, /, arg): - """self //= arg, element-by-element in-place floor division. + """``self //= arg``, element-by-element in-place floor division. Cases of divide-by-zero are masked. Derivatives are ignored. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: self after the floor division. + + Raises: + ValueError: If this object is read-only. """ self.require_writeable() @@ -922,7 +1026,14 @@ def __ifloordiv__(self, /, arg): def _floordiv_by_number(self, /, arg): - """Internal floor division op when the arg is a Python scalar.""" + """Internal floor division op when the arg is a Python scalar. + + Parameters: + arg (float | int | bool): The number by which to divide this object. + + Returns: + Qube: The floor of the quotient. + """ obj = self._clone_new_values(recursive=False, retain_cache=True) @@ -935,9 +1046,15 @@ def _floordiv_by_number(self, /, arg): def _floordiv_by_scalar(self, /, arg): - """Internal floor division op when the arg is a Qube with nrank == 0. + """Internal floor division op when the arg is a Qube with ``nrank == 0``. The arg cannot have a denominator. + + Parameters: + arg (Qube): The Scalar by which to divide this object. + + Returns: + Qube: The floor of the quotient. """ # Mask out zeros @@ -961,14 +1078,14 @@ def _floordiv_by_scalar(self, /, arg): ########################################################################################## def __mod__(self, /, arg, *, recursive=True): - """self % arg, element-by-element modulus. + """``self % arg``, element-by-element modulus. Cases of divide-by-zero are masked. Derivatives in the numerator are supported, but not in the denominator. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -1008,14 +1125,14 @@ def __mod__(self, /, arg, *, recursive=True): def __rmod__(self, /, arg, *, recursive=True): - """arg % self, element-by-element modulus. + """``arg % self``, element-by-element modulus. Cases of divide-by-zero are masked. Derivatives in the numerator are supported, but not in the denominator. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). recursive (bool, optional): True to include derivatives in return. Returns: @@ -1036,17 +1153,20 @@ def __rmod__(self, /, arg, *, recursive=True): def __imod__(self, /, arg): - """self %= arg, element-by-element in-place modulus. + """``self %= arg``, element-by-element in-place modulus. Cases of divide-by-zero are masked. Derivatives in the numerator are supported, but not in the denominator. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The argument. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: self after the modulus operation. + + Raises: + ValueError: If this object is read-only. """ self.require_writeable() @@ -1085,7 +1205,15 @@ def __imod__(self, /, arg): def _mod_by_number(self, /, arg, *, recursive=True): - """Internal modulus op when the arg is a Python scalar.""" + """Internal modulus op when the arg is a Python scalar. + + Parameters: + arg (float | int | bool): The number by which to take the modulus. + recursive (bool, optional): True to include the derivatives in the result. + + Returns: + Qube: The remainder. + """ obj = self._clone_new_values(recursive=False, retain_cache=True) @@ -1103,7 +1231,15 @@ def _mod_by_number(self, /, arg, *, recursive=True): def _mod_by_scalar(self, /, arg, *, recursive=True): - """Internal modulus op when the arg is a Qube with rank == 0.""" + """Internal modulus op when the arg is a Qube with ``rank == 0``. + + Parameters: + arg (Qube): The Scalar by which to take the modulus. + recursive (bool, optional): True to include the derivatives in the result. + + Returns: + Qube: The remainder. + """ # Mask out zeros arg = arg.wod.mask_where_eq(0, 1) @@ -1131,22 +1267,25 @@ def _mod_by_scalar(self, /, arg, *, recursive=True): ########################################################################################## def __pow__(self, /, arg): - """self ** arg, element-by-element exponentiation. - - Derivatives are not supported. + """``self ** arg``, element-by-element exponentiation. - This general method supports single integer exponents between -15 and 15 are handled - using repeated multiplications. It will handle any class that supports __mul__() (and - reciprocal() if the exponent is negative), such as Matrix objects and Quaternions. + This general method supports a single integer exponent between -15 and 15, which is + handled using repeated multiplications. It will handle any class that supports + ``__mul__()`` (and ``reciprocal()`` if the exponent is negative), such as Matrix + objects and Quaternions. Derivatives are included in the result. - It is overridden by Scalar to obtain the normal behavior of the "**" operator. + It is overridden by Scalar to obtain the normal behavior of the ``**`` operator. Parameters: - arg (Qube, array-like, float, int, or bool): The exponent. If not a Qube object, - it will be converted to a Scalar via Qube._SCALAR_CLASS.as_scalar(). + arg (QubeLike): The exponent. If not a Qube object, it will be converted to a + Scalar via Qube._SCALAR_CLASS.as_scalar(). Returns: Qube: The result of the exponentiation. + + Raises: + ValueError: If the exponent is outside the range -15 to 15. + TypeError: If the exponent is not a single integer. """ if not isinstance(arg, numbers.Real): @@ -1227,7 +1366,7 @@ def __ipow__(self, /, arg): unit of this object beforehand. Parameters: - arg (Qube, array-like, float, int, or bool): The exponent. + arg (QubeLike): The exponent. Returns: Qube: self after the exponentiation. @@ -1262,8 +1401,14 @@ def __ipow__(self, /, arg): ########################################################################################## def _compatible_arg(self, /, arg): - """None if it is impossible for self and arg to be equal; otherwise, the argument made - compatible with self. + """The argument made compatible with this object, or None if equality is impossible. + + Parameters: + arg (QubeLike): The object to be made compatible with this object. + + Returns: + Qube | None: The argument made compatible with this object, or None if the two can + never be equal. """ # If the subclasses cannot be unified, raise a ValueError @@ -1294,13 +1439,14 @@ def _compatible_arg(self, /, arg): def __eq__(self, /, arg): - """self == arg, element by element. + """``self == arg``, element by element. Parameters: - arg (Qube, array-like, float, int, or bool): The exponent. + arg (Any): The object to compare with `self`. Returns: - Boolean: True where the elements are equal. + Boolean | bool: True where the elements are equal. The result is a Python bool if + this object has shape () or if the two objects can never be equal. """ # Try to make argument compatible @@ -1340,13 +1486,14 @@ def __eq__(self, /, arg): def __ne__(self, /, arg): - """self != arg, element by element. + """``self != arg``, element-by-element inequality. Parameters: - arg (Qube, array-like, float, int, or bool): The exponent. + arg (Any): The object to compare with `self`. Returns: - Boolean: True where the elements are not equal. + Boolean | bool: True where the elements are not equal. The result is a Python bool + if this object has shape () or if the two objects can never be equal. """ # Try to make argument compatible @@ -1391,102 +1538,104 @@ def __ne__(self, /, arg): def __le__(self, /, arg): - """self <= arg, element by element. - - This general method always raises ValueError. It is overriden by :meth:`Scalar.__le__` - and :meth:`Boolean.__le__`. + """``self <= arg``, element by element. + This general method always raises TypeError. It is overridden by + :meth:`Scalar.__le__` and :meth:`Boolean.__le__`. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. + arg (Any): The object to compare with `self`. Returns: Boolean: True where the elements of self are less or equal. Raises: - ValueError: If the comparison is undefined. + TypeError: If the comparison is undefined. """ _raise_unsupported_op("<=", self) def __lt__(self, /, arg): - """self < arg, element by element. + """``self < arg``, element by element. - This general method always raises ValueError. It is overriden by :meth:`Scalar.__lt__` - and :meth:`Boolean.__lt__`. + This general method always raises TypeError. It is overridden by + :meth:`Scalar.__lt__` and :meth:`Boolean.__lt__`. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. + arg (Any): The object to compare with `self`. Returns: Boolean: True where the elements of self are less. Raises: - ValueError: If the comparison is undefined. + TypeError: If the comparison is undefined. """ _raise_unsupported_op("<", self) def __ge__(self, /, arg): - """self >= arg, element by element. + """``self >= arg``, element by element. - This general method always raises ValueError. It is overriden by :meth:`Scalar.__ge__` - and :meth:`Boolean.__ge__`. + This general method always raises TypeError. It is overridden by + :meth:`Scalar.__ge__` and :meth:`Boolean.__ge__`. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. + arg (Any): The object to compare with `self`. Returns: Boolean: True where the elements of self are greater or equal. Raises: - ValueError: If the comparison is undefined. + TypeError: If the comparison is undefined. """ _raise_unsupported_op(">=", self) def __gt__(self, /, arg): - """self > arg, element by element. + """``self > arg``, element by element. - This general method always raises ValueError. It is overriden by :meth:`Scalar.__gt__` - and :meth:`Boolean.__gt__`. + This general method always raises TypeError. It is overridden by + :meth:`Scalar.__gt__` and :meth:`Boolean.__gt__`. Parameters: - arg (Qube, array-like, float, int, or bool): The argument. + arg (Any): The object to compare with `self`. Returns: Boolean: True where the elements of self are greater. Raises: - ValueError: If the comparison is undefined. + TypeError: If the comparison is undefined. """ _raise_unsupported_op(">", self) def __bool__(self): - """True if nonzero, otherwise False, element by element. - - This method also supports "if a == b: ..." and "if a != b: ..." statements using the - internal attributes _truth_if_all and _truth_if_any. These attributes are set by - the __eq__() and __ne__() methods respectively. When _truth_if_all is True (set by - __eq__()), the result is True only if all unmasked elements are True. When - _truth_if_any is True (set by __ne__()), the result is True if any unmasked element - is True. - - In this case, equality requires that every unmasked element of a and b be equal and - both objects be masked at the same locations. - - Comparison of objects of shape () is also supported. - - Any other if-test involving PolyMath objects requires an explict call to all() or - any(). + """True if nonzero, otherwise False. Returns: - Boolean: True where the elements of self are nonzero or True. + bool: True if the elements of self are nonzero or True. + + Notes: + This method also supports ``if a == b: ...`` and ``if a != b: ...`` statements + using the internal attributes `_truth_if_all` and `_truth_if_any`. These + attributes are set by the `__eq__()` and `__ne__()` methods respectively. When + `_truth_if_all` is True (set by `__eq__()`), the result is True only if all + unmasked elements are True. When `_truth_if_any` is True (set by `__ne__()`), the + result is True if any unmasked element is True. In this case, equality requires + that every unmasked element of ``a`` and ``b`` be equal and both objects be masked + at the same locations. Comparison of objects with ``shape == ()`` is also + supported. + + Any other if-test involving PolyMath objects requires an explicit call to `all()` + or `any()`. + + Raises: + ValueError: If this object is an array not produced by ``==`` or ``!=``, or if it + is entirely masked. """ if self._truth_if_all: # this is the result of __eq__() @@ -1506,7 +1655,14 @@ def __bool__(self): def __float__(self): - """This object as a single float.""" + """This object as a single float. + + Returns: + float: This object's single value as a Python float. + + Raises: + ValueError: If this object is an array or is masked. + """ if not self._is_scalar: raise ValueError(f'{type(self).__name__} array value cannot be converted to ' @@ -1519,7 +1675,14 @@ def __float__(self): def __int__(self): - """This object as a single int; floats always round down.""" + """This object as a single int; floats always round down. + + Returns: + int: This object's single value as a Python int. + + Raises: + ValueError: If this object is an array or is masked. + """ if not self._is_scalar: raise ValueError(f'{type(self).__name__} array value cannot be converted to int') @@ -1533,16 +1696,26 @@ def __int__(self): ########################################################################################## def __invert__(self): - """~self, unary inversion, element by element. + """``~self``, unary inversion, element by element. This is boolean "not", not bit inversion. + + Returns: + Boolean: True where this object is zero or masked. """ return Qube._BOOLEAN_CLASS(self._values == 0, self._mask) def __and__(self, /, arg): - """self & arg, element-by-element logical "and".""" + """``self & arg``, element-by-element logical "and". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ if isinstance(arg, np.ma.MaskedArray): arg = Qube._BOOLEAN_CLASS(arg != 0) @@ -1557,13 +1730,27 @@ def __and__(self, /, arg): def __rand__(self, /, arg): - """arg & self, element-by-element logical "and".""" + """``arg & self``, element-by-element logical "and". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ return self.__and__(arg) def __or__(self, /, arg): - """self | arg, element-by-element logical "or".""" + """``self | arg``, element-by-element logical "or". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ if isinstance(arg, np.ma.MaskedArray): arg = Qube._BOOLEAN_CLASS(arg != 0) @@ -1577,13 +1764,27 @@ def __or__(self, /, arg): self._mask, nrank=0) def __ror__(self, /, arg): - """arg | self, element-by-element logical "or".""" + """``arg | self``, element-by-element logical "or". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ return self.__or__(arg) def __xor__(self, /, arg): - """self | arg, element-by-element logical exclusive "or".""" + """``self ^ arg``, element-by-element logical "xor". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ if isinstance(arg, np.ma.MaskedArray): arg = Qube._BOOLEAN_CLASS(arg != 0) @@ -1597,13 +1798,30 @@ def __xor__(self, /, arg): self._mask, nrank=0) def __rxor__(self, /, arg): - """arg | self, element-by-element logical exclusive "or".""" + """``arg ^ self``, element-by-element logical "xor". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Boolean: The element-by-element result. + """ return self.__xor__(arg) def __iand__(self, /, arg): - """self &= arg, element-by-element in-place logical "and".""" + """``self &= arg``, element-by-element in-place logical "and". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Qube: This object, modified in place. + + Raises: + ValueError: If this object is read-only. + """ self.require_writeable() @@ -1620,7 +1838,17 @@ def __iand__(self, /, arg): def __ior__(self, /, arg): - """self &= arg, element-by-element in-place logical "or".""" + """``self |= arg``, element-by-element in-place logical "or". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Qube: This object, modified in place. + + Raises: + ValueError: If this object is read-only. + """ self.require_writeable() @@ -1637,7 +1865,17 @@ def __ior__(self, /, arg): def __ixor__(self, /, arg): - """self ^= arg, element-by-element in-place logical exclusive "or".""" + """``self ^= arg``, element-by-element in-place logical "xor". + + Parameters: + arg (BooleanLike): The right operand of the operation. + + Returns: + Qube: This object, modified in place. + + Raises: + ValueError: If this object is read-only. + """ self.require_writeable() @@ -1654,7 +1892,11 @@ def __ixor__(self, /, arg): def logical_not(self): - """The negation of this object, True where it is zero or False.""" + """The negation of this object, True where it is zero or False. + + Returns: + Boolean: True where this object is zero and unmasked. + """ if self._rank: values = np.any(self._values, axis=tuple(range(-self._rank, 0))) @@ -1671,20 +1913,21 @@ def any(self, axis=None, *, builtins=None, masked=None, out=None): """True if any of the unmasked items are nonzero. Parameters: - axis (int or tuple, optional): Axis or a tuple of axes. The `any` operation is - performed across these axes, leaving any remaining axes in the returned value. - If None (the default), then the any operation is performed across all axes of - the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked Boolean instead of a builtin - type in this case. - out (object, optional): Ignored. This enables "np.any(Qube)" to work. + axis (int | tuple[int, ...] | None, optional): Axis or a tuple of axes. The `any` + operation is performed across these axes, leaving any remaining axes in the + returned value. If None (the default), then the any operation is performed + across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked Boolean instead of a + builtin type in this case. + out (Any, optional): Ignored. This enables ``np.any(Qube)`` to work. Returns: - (Boolean or bool): Result of operation. + Boolean | bool: True if any unmasked element is nonzero. """ self = Qube._BOOLEAN_CLASS.as_boolean(self) @@ -1716,17 +1959,21 @@ def all(self, axis=None, *, builtins=None, masked=None, out=None): """True if all the unmasked items are nonzero. Parameters: - axis (int or tuple, optional): Axis or a tuple of axes. The any operation is - performed across these axes, leaving any remaining axes in the returned value. - If None (the default), then the any operation is performed across all axes of - the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked Boolean instead of a builtin - type in this case. - out (object, optional): Ignored. This enables "np.any(Qube)" to work. + axis (int | tuple[int, ...] | None, optional): Axis or a tuple of axes. The `all` + operation is performed across these axes, leaving any remaining axes in the + returned value. If None (the default), then the all operation is performed + across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked Boolean instead of a + builtin type in this case. + out (Any, optional): Ignored. This enables ``np.all(Qube)`` to work. + + Returns: + Boolean | bool: True if every unmasked element is nonzero. """ self = Qube._BOOLEAN_CLASS.as_boolean(self) @@ -1757,17 +2004,22 @@ def all(self, axis=None, *, builtins=None, masked=None, out=None): def any_true_or_masked(self, axis=None, *, builtins=None): """True if any of the items are nonzero or masked. - This differs from the any() method in how it handles the case of every value being - masked. This method returns True, whereas any() returns a masked Boolean value. + This differs from :meth:`~polymath.Qube.any` in how it handles the case of every + value being masked. This method returns True, whereas :meth:`~polymath.Qube.any` + returns a masked Boolean value. Parameters: - axis (int or tuple, optional): Axis or a tuple of axes. The any operation is - performed across these axes, leaving any remaining axes in the returned value. - If None (the default), then the any operation is performed across all axes of - the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + axis (int | tuple[int, ...] | None, optional): Axis or a tuple of axes. The any + operation is performed across these axes, leaving any remaining axes in the + returned value. If None (the default), then the any operation is performed + across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + + Returns: + Boolean | bool: True if any element is nonzero or masked. """ self = Qube._BOOLEAN_CLASS.as_boolean(self) @@ -1795,18 +2047,22 @@ def any_true_or_masked(self, axis=None, *, builtins=None): def all_true_or_masked(self, axis=None, *, builtins=None): """True if all of the items are nonzero or masked. - This differs from the all() method in how it handles the case of every value being - masked. This method returns True, whereas all() returns a masked Boolean value. + This differs from :meth:`~polymath.Qube.all` in how it handles the case of every + value being masked. This method returns True, whereas :meth:`~polymath.Qube.all` + returns a masked Boolean value. Parameters: - axis (int or tuple, optional): Axis or a tuple of axes. The any operation is - performed across these axes, leaving any remaining axes in the returned value. - If None (the default), then the any operation is performed across all axes of - the object. + axis (int | tuple[int, ...] | None, optional): Axis or a tuple of axes. The all + operation is performed across these axes, leaving any remaining axes in the + returned value. If None (the default), then the all operation is performed + across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + Returns: + Boolean | bool: True if every element is nonzero or masked. """ self = Qube._BOOLEAN_CLASS.as_boolean(self) @@ -1838,15 +2094,18 @@ def reciprocal(self, *, recursive=True, nozeros=False): """An object equivalent to the reciprocal of this object. This method is not implemented for the base class. It is overridden by - :meth:`Scalar.reciprocal`, :meth:`Vector.reciprocal`, :meth:`Matrix.reciprocal`, and - :meth:`Quaternion.reciprocal`. + :meth:`~polymath.Scalar.reciprocal`, :meth:`~polymath.Vector.reciprocal`, + :meth:`~polymath.Matrix.reciprocal`, and :meth:`~polymath.Quaternion.reciprocal`. - Input: + Parameters: recursive (bool, optional): True to return the derivatives of the reciprocal too; otherwise, derivatives are removed. nozeros (bool, optional): False (the default) to mask out any zero-valued items in this object prior to the divide. Set to True only if you know in advance that this object has no zero-valued items. + + Returns: + Qube: The reciprocal of this object. """ _raise_unsupported_op('reciprocal()', self) @@ -1858,6 +2117,9 @@ def zero(self): The returned object has the same denominator shape as this object. This is default behavior and may need to be overridden by some subclasses. + + Returns: + Qube: An object of this subclass containing all zeros. """ # Scalar case @@ -1885,8 +2147,12 @@ def zero(self): def identity(self): """An object of this subclass equivalent to the identity. - This method is overridden by :meth:`Scalar.identity`, :meth:`Matrix.identity` and - :meth:`Boolean.identity` + This method is not implemented for the base class. It is overridden by + :meth:`~polymath.Scalar.identity`, :meth:`~polymath.Matrix.identity`, and + :meth:`~polymath.Boolean.identity`. + + Returns: + Qube: The identity object. """ _raise_unsupported_op('identity()', self) @@ -1895,22 +2161,26 @@ def identity(self): def sum(self, axis=None, *, recursive=True, builtins=None, masked=None, out=None): """The sum of the unmasked values along the specified axis or axes. - This method is overridden by :meth:`Boolean.sum`. + This method is overridden by :meth:`~polymath.Boolean.sum`. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The sum is - determined across these axes, leaving any remaining axes in the returned - value. If None (the default), then the sum is performed across all axes if the - object. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of axes. + The sum is determined across these axes, leaving any remaining axes in the + returned value. If None (the default), then the sum is performed across all + axes of the object. recursive (bool, optional): True to include the sums of the derivatives inside the returned Scalar. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. - out (optional): Ignored. This enables "np.sum(Qube)" to work. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of as an + instance of Scalar. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (float | int | None, optional): The value to return if `builtins` is True + but the returned value is masked. Default is to return a masked value instead + of a builtin type. + out (Any, optional): Ignored. This enables ``np.sum(Qube)`` to work. + + Returns: + Qube | float | int: The sum across the specified axes. """ result = self._mean_or_sum(axis, recursive=recursive, _combine_as_mean=False) @@ -1930,27 +2200,32 @@ def mean(self, axis=None, *, recursive=True, builtins=None, masked=None, dtype=N """The mean of the unmasked values along the specified axis or axes. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The mean is - determined across these axes, leaving any remaining axes in the returned - value. If None (the default), then the mean is performed across all axes of - the object. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of axes. + The mean is determined across these axes, leaving any remaining axes in the + returned value. If None (the default), then the mean is performed across all + axes of the object. recursive (bool, optional): True to include the means of the derivatives inside the returned Scalar. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. - dtype (optional): Ignored. This enables "np.mean(Qube)" to work. - out (optional): Ignored. This enables "np.mean(Qube)" to work. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of as an + instance of Scalar. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (float | int | None, optional): The value to return if `builtins` is True + but the returned value is masked. Default is to return a masked value instead + of a builtin type. + dtype (Any, optional): Ignored. This enables ``np.mean(Qube)`` to work. + out (Any, optional): Ignored. This enables ``np.mean(Qube)`` to work. + + Returns: + Qube | float | int: The mean across the specified axes. Examples: - For an object with shape (2, 3, 2): - - axis=0 → result shape (3, 2) - - axis=1 → result shape (2, 2) - - axis=(0, 1) → result shape (2,) - - axis=None → result shape () + For an object with shape (2, 3, 2):: + + axis=0 -> result shape (3, 2) + axis=1 -> result shape (2, 2) + axis=(0, 1) -> result shape (2,) + axis=None -> result shape () """ result = self._mean_or_sum(axis, recursive=recursive, _combine_as_mean=True) diff --git a/src/polymath/extensions/pickler.py b/src/polymath/extensions/pickler.py index 5b08b3a..18f0453 100644 --- a/src/polymath/extensions/pickler.py +++ b/src/polymath/extensions/pickler.py @@ -1,7 +1,7 @@ -######################################################################################## -# polymath/extensions/pickle.py -######################################################################################## -"""This module supports the "pickling" of polymath objects. +########################################################################################## +# polymath/extensions/pickler.py +########################################################################################## +"""This module supports the "pickling" of PolyMath objects. Because objects such as backplanes can be numerous and also quite large, we provide a variety of methods, both lossless and lossy, for compressing them during storage. As one @@ -29,14 +29,14 @@ compression method using :meth:`~polymath.Qube.set_default_pickle_digits`. The inputs to these functions are as follows: -**digits** (`str or int`): The number of digits to preserve. +**digits** (`str | int`): The number of digits to preserve. * "double": preserve full precision using lossless **fpzip** compression. * "single": convert the array to single precision and then store it using lossless **fpzip** compression. * an integer 7-16, defining the number of significant digits to preserve. -**reference** (`str or float`): How to interpret a numeric value of **digits**. +**reference** (`str | float`): How to interpret a numeric value of **digits**. * "fpzip": Use lossy **fpzip** compression, preserving the given number of digits. * a number: Preserve every number to the exact same absolute precision, scaling the number @@ -47,23 +47,23 @@ The remaining options for **reference** provide a variety of ways to allow a reference number to be generated automatically. -* "smallest": Absolute accuracy will be 10**(-digits) times the non-zero array value +* "smallest": Absolute accuracy will be ``10**(-digits)`` times the non-zero array value closest to zero. This option guarantees that every value will preserve at least the requested number of digits. This is reasonable if you expect all values to fall within a similar dynamic range. -* "largest": Absolute accuracy will be 10**(-digits) times the value in the array furthest - from zero. This option is useful for arrays that contain a limited range of values, such - as the components of a unit vector or angles that are known to fall between zero and - 2*pi. In this case, it is probably not necessary to preserve the extra precision in - values that just happen to fall very close zero. -* "mean": Absolute accuracy will be 10**(-digits) times the mean of the absolute values - in the array. -* "median": UAbsolute accuracy will be 10**(-digits) times the median of the absolute +* "largest": Absolute accuracy will be ``10**(-digits)`` times the value in the array + furthest from zero. This option is useful for arrays that contain a limited range of + values, such as the components of a unit vector or angles that are known to fall between + zero and 2*pi. In this case, it is probably not necessary to preserve the extra + precision in values that just happen to fall very close to zero. +* "mean": Absolute accuracy will be ``10**(-digits)`` times the mean of the absolute + values in the array. +* "median": Absolute accuracy will be ``10**(-digits)`` times the median of the absolute values in the array. This is a good choice if a minority of values in the array are very different from the others, such as noise spikes or undefined geometry. In such a case, we want the precision to be based on the more "typical" values. -* "logmean": Absolute accuracy will be 10**(-digits) times the log-mean of the absolute - values in the array. +* "logmean": Absolute accuracy will be ``10**(-digits)`` times the log-mean of the + absolute values in the array. """ import bz2 @@ -98,35 +98,45 @@ @staticmethod def _pickle_debug(debug): + """Enable or disable the printing of pickle diagnostics. + + Parameters: + debug (bool): True to print diagnostic information while pickling. + """ + global _PICKLE_DEBUG _PICKLE_DEBUG = debug def set_pickle_digits(self, digits='double', reference='fpzip'): - """Set the desired number of decimal digits of precision in the storage of this - object's floating-point values and their derivatives. + """Set the decimal digits of precision to preserve when pickling this object. - This attribute is ignored for integer and boolean values. The method will still - set the attribute on the object, but it will not be used during pickling of - integer or boolean arrays. + The setting applies to the floating-point values of this object and to those of its + derivatives. It is ignored for integer and boolean values. The method still sets the + attribute on the object, but it is not used during pickling of integer or boolean + arrays. Parameters: - digits (int, float, str or tuple, optional): - The number of digits to preserve when pickling this object. If two values are - given, the second applies to any derivatives. If a number is specified, this - is the number of decimal digits to preserve when this object is pickled. It - need not be an integer. It is truncated to the range supported by single and - double precision. Alternatively, use "double" to preserve full double - precision; use "single" for single precision. - - reference (int, float, str or tuple, optional): - A value defining the number to use when assessing how many digits are - preserved. If two values are given, the second applies to any derivatives. If - a number is specified, the number of `digits` will be relative to this value. - For example, if the `reference=100` and `digits=8`, the absolute precision - will be 1.e-6. Alternatively, use one of these strings to let the precision be - referenced to the values in the array: "smallest", "largest", "mean", - "median", "logmean", or "fpzip". + digits (int | float | str | list | tuple | None, optional): The number of digits + to preserve when pickling this object. If two values are given, the second + applies to any derivatives. If a number is specified, this is the number of + decimal digits to preserve when this object is pickled. It need not be an + integer. It is truncated to the range supported by single and double + precision. Alternatively, use "double" to preserve full double precision; use + "single" for single precision. None is equivalent to "double". + reference (int | float | str | list | tuple | None, optional): A value defining + the number to use when assessing how many digits are preserved. If two values + are given, the second applies to any derivatives. If a number is specified, + the number of `digits` will be relative to this value. For example, if the + `reference=100` and `digits=8`, the absolute precision will be 1.e-6. + Alternatively, use one of these strings to let the precision be referenced to + the values in the array: "smallest", "largest", "mean", "median", "logmean", + or "fpzip". None is equivalent to "fpzip". + + Raises: + ValueError: If a value of `digits` is neither a number nor "single" or "double", + or if a value of `reference` is neither a number nor one of the recognized + names. Notes: The reference options are: @@ -164,25 +174,32 @@ def set_pickle_digits(self, digits='double', reference='fpzip'): @staticmethod def set_default_pickle_digits(digits='double', reference='fpzip'): - """Set the default number of decimal digits of precision in the storage of - floating-point values and their derivatives. + """Set the default decimal digits of precision to preserve when pickling. + + The default applies to the floating-point values and derivatives of every object that + has no setting of its own from :meth:`~polymath.Qube.set_pickle_digits`. Parameters: - digits (int, float, str or tuple, optional): - The number of digits to preserve when pickling this object. If two values are - given, the second applies to any derivatives. If a number is specified, this - is the number of decimal digits to preserve when this object is pickled. It - need not be an integer. It is truncated to the range supported by single and - double precision. Alternatively, use "double" to preserve full double - precision; use "single" for single precision. - reference (int, float, str or tuple, optional): - A value defining the number to use when assessing how many digits are - preserved. If two values are given, the second applies to any derivatives. If - a number is specified, the number of `digits` will be relative to this value. - For example, if the `reference=100` and `digits=8`, the precision will be - 1.e-6. Alternatively, use one of these strings to let the precision be - referenced to the values in the array: "smallest", "largest", "mean", - "median", "logmean", or "fpzip". + digits (int | float | str | list | tuple | None, optional): The number of digits + to preserve when pickling an object. If two values are given, the second + applies to any derivatives. If a number is specified, this is the number of + decimal digits to preserve when an object is pickled. It need not be an + integer. It is truncated to the range supported by single and double + precision. Alternatively, use "double" to preserve full double precision; use + "single" for single precision. None is equivalent to "double". + reference (int | float | str | list | tuple | None, optional): A value defining + the number to use when assessing how many digits are preserved. If two values + are given, the second applies to any derivatives. If a number is specified, + the number of `digits` will be relative to this value. For example, if the + `reference=100` and `digits=8`, the absolute precision will be 1.e-6. + Alternatively, use one of these strings to let the precision be referenced to + the values in the array: "smallest", "largest", "mean", "median", "logmean", + or "fpzip". None is equivalent to "fpzip". + + Raises: + ValueError: If a value of `digits` is neither a number nor "single" or "double", + or if a value of `reference` is neither a number nor one of the recognized + names. Notes: The reference options are: @@ -210,12 +227,12 @@ def set_default_pickle_digits(digits='double', reference='fpzip'): def pickle_digits(self): - """The digits of floating-point precision to include when pickling this object and its - derivatives. + """The digits of floating-point precision to preserve when pickling this object. Returns: - (str, float, or int): One of "double", "single", or a number of digits roughly in - the range 7-16. + tuple[str | float | int, str | float | int]: The setting for this object and the + one for its derivatives, each either "double", "single", or a number of digits + roughly in the range 7-16. """ if not hasattr(self, '_pickle_digits') or self._pickle_digits is None: @@ -225,12 +242,12 @@ def pickle_digits(self): def pickle_reference(self): - """The reference value to use when determining the number of digits of floating-point - precision in this object and its derivatives. + """The reference value for the digits of precision to preserve when pickling. Returns: - (str, float, or int): One of "fpzip", "smallest", "largest", "mean", "median", - "logmean", or a number. + tuple[str | float | int, str | float | int]: The setting for this object and the + one for its derivatives, each either "fpzip", "smallest", "largest", "mean", + "median", "logmean", or a number. """ if (not hasattr(self, '_pickle_reference') @@ -269,7 +286,7 @@ def _validate_pickle_digits(digits, reference): """Validate and return the pickle digit values. Parameters: - digits (int, float, str, list, tuple, or None): A single value, or one value for + digits (int | float | str | list | tuple | None): A single value, or one value for an object and a second for its derivatives. Each value is a number of decimal digits, "single", or "double". Use None for "double". Values beyond the first two are ignored. @@ -317,7 +334,7 @@ def _validate_pickle_reference(references): """Validate and return the pickle reference values. Parameters: - references (int, float, str, list, tuple, or None): A single value, or one value + references (int | float | str | list | tuple | None): A single value, or one value for an object and a second for its derivatives. Each value is a number or one of "smallest", "largest", "mean", "median", "logmean", or "fpzip". Use None for "fpzip". Values beyond the first two are ignored. @@ -350,12 +367,24 @@ def _validate_pickle_reference(references): return references -################################################################################ +########################################################################################## # Support for fpzip compression and decompression -################################################################################ +########################################################################################## def fpzip_compress(array, digits=16, dtype=np.float64): - """An fpzip-compressed array plus the number of bits that have been zeroed.""" + """An fpzip-compressed array plus the number of bits that have been zeroed. + + Parameters: + array (numpy.ndarray): The floating-point array to compress. + digits (int | float, optional): The number of decimal digits of precision to + preserve. + dtype (type, optional): The NumPy floating-point type to which `array` is cast + before compression. + + Returns: + tuple[bytes, int]: The compressed bytes and the number of low-order mantissa bits + zeroed. + """ array = np.require(array, dtype=dtype, requirements=['C', 'A', 'W']) shape = array.shape @@ -450,7 +479,18 @@ def fpzip_compress(array, digits=16, dtype=np.float64): def fpzip_decompress(fpzip_bytes, shape, bits): - """An fpzip-decompressed array with compensation for any compression bias.""" + """An fpzip-decompressed array with compensation for any compression bias. + + Parameters: + fpzip_bytes (bytes): The compressed array as returned by + :func:`~polymath.extensions.pickler.fpzip_compress`. + shape (tuple[int, ...]): The shape of the array to reconstruct. + bits (int): The number of low-order mantissa bits that were zeroed during + compression, used to compensate for the resulting bias. + + Returns: + numpy.ndarray: The decompressed array. + """ floats = fpzip.decompress(fpzip_bytes).astype(np.float64).reshape(shape) @@ -497,9 +537,9 @@ def fpzip_decompress(fpzip_bytes, shape, bits): return floats -################################################################################ +########################################################################################## # Support for compression using integers plus an offset and scale factor -################################################################################ +########################################################################################## def _encode_one_float_array(values, digits, reference): """Encode one array into a tuple for the specified digits precision. @@ -507,7 +547,7 @@ def _encode_one_float_array(values, digits, reference): Parameters: values (numpy.ndarray): Array of floats to encode. digits (float): Number of digits to preserve. - reference (str or float): One of 'smallest', 'largest', 'mean', 'median', + reference (str | float): One of 'smallest', 'largest', 'mean', 'median', 'logmean', 'fpzip', or a number. Returns: @@ -604,10 +644,14 @@ def _encode_one_float_array(values, digits, reference): def _encode_floats(values, rank, digits, reference): """Complete encoding of a floating-point array. - A tuple is returned in one of these forms: + A tuple is returned in one of these forms:: + ('literal', array) - ('float64', shape, fpzipped array) - ('float32', shape, fpzipped array) + ('float64', shape, zeroed bits, fpzipped array) + ('float32', shape, zeroed bits, fpzipped array) + ('fpzip', shape, zeroed bits, fpzipped array) + where: + zeroed bits is the number of low-order mantissa bits discarded ('constant', shape, single value) ('scaled', shape, dtype, nbytes, scale_factor, offset, bz-compressed unsigned ints) @@ -621,14 +665,13 @@ def _encode_floats(values, rank, digits, reference): Parameters: values (numpy.ndarray): Array of values to encode. rank (int): Rank of the individual items in this array. - digits (str or float): 'float64', 'float32', or number of digits to - preserve. - reference (str): One of 'smallest', 'largest', 'mean', 'median', - 'logmean', or 'fpzip'. + digits (str | float): 'double', 'single', or the number of digits to preserve. + reference (str | float): One of 'smallest', 'largest', 'mean', 'median', + 'logmean', 'fpzip', or a number. Returns: - tuple: Encoded array in one of several formats depending on the - compression method used. + tuple: Encoded array in one of several formats depending on the compression method + used. """ shape = values.shape @@ -669,7 +712,14 @@ def _encode_floats(values, rank, digits, reference): def _decode_scaled_uints(encoded): - """Decode a scaled, compressed array of unsigned integers.""" + """Decode a scaled, compressed array of unsigned integers. + + Parameters: + encoded (tuple): The encoded array as written by the matching encoder. + + Returns: + numpy.ndarray: The reconstructed array. + """ (_, shape, dtype, nbytes, scale_factor, offset, bz2_bytes) = encoded bz2_ints = np.frombuffer(bz2.decompress(bz2_bytes), dtype=dtype) @@ -694,7 +744,14 @@ def _decode_scaled_uints(encoded): def _decode_floats(encoded): - """Complete decoding of a floating-point array.""" + """Complete decoding of a floating-point array. + + Parameters: + encoded (tuple): The encoded array as written by the matching encoder. + + Returns: + numpy.ndarray: The reconstructed floating-point array. + """ method = encoded[0] @@ -733,7 +790,14 @@ def _decode_floats(encoded): def _encode_ints(values): - """Encode an integer array using BZ2 compression.""" + """Encode an integer array using BZ2 compression. + + Parameters: + values (numpy.ndarray): The integer array to encode. + + Returns: + bytes: The compressed array. + """ if not values.flags['CONTIGUOUS']: values = values.copy() @@ -742,14 +806,29 @@ def _encode_ints(values): def _decode_ints(values, shape): - """Decode an integer array using BZ2 decompression.""" + """Decode an integer array using BZ2 decompression. + + Parameters: + values (bytes): The compressed array. + shape (tuple[int, ...]): The shape of the array to reconstruct. + + Returns: + numpy.ndarray: The reconstructed integer array. + """ bz2_bytes = bz2.decompress(values) return np.frombuffer(bz2_bytes, dtype='int').reshape(shape) def _encode_bools(values): - """Encode a boolean array using packbits + BZ2 compression.""" + """Encode a boolean array using packbits + BZ2 compression. + + Parameters: + values (numpy.ndarray): The boolean array to encode. + + Returns: + bytes: The compressed array. + """ if not values.flags['CONTIGUOUS']: values = values.copy() @@ -758,7 +837,17 @@ def _encode_bools(values): def _decode_bools(values, shape, size): - """Decode a boolean array using BZ2 decompression.""" + """Decode a boolean array using BZ2 decompression. + + Parameters: + values (bytes): The compressed array. + shape (tuple[int, ...]): The shape of the array to reconstruct. + size (int): The number of boolean values, needed because the packed representation + is padded to a whole number of bytes. + + Returns: + numpy.ndarray: The reconstructed boolean array. + """ bz2_bytes = bz2.decompress(values) packed = np.frombuffer(bz2_bytes, dtype='uint8') @@ -766,9 +855,9 @@ def _decode_bools(values, shape, size): bools = bools[:size] return bools.reshape(shape) -################################################################################ +########################################################################################## # __getstate__ and __setstate__ -################################################################################ +########################################################################################## def __getstate__(self): """The state is defined by a dictionary containing most of the Qube attributes. @@ -779,7 +868,7 @@ def __getstate__(self): "PICKLE_VERSION" is added, with a value defined by the current version. - New attribute "MASK_ENCODING" is a list of the steps that have been applied to the + The attribute "MASK_ENCODING" is a list of the steps that have been applied to the mask. Each item in the list is a tuple, one of: * ('CORNERS', corners), where corners is the tuple returned by Qube._find_corners() @@ -788,19 +877,22 @@ def __getstate__(self): The list will be empty if no compression has been applied. - New attribute "VALS_ENCODING" is a list of the steps that have been applied to the + The attribute "VALS_ENCODING" is a list of the steps that have been applied to the values. Each item in the list is a tuple, one of: + * ('ALL_MASKED',) if the object is fully masked, so no values are saved. * ('ANTIMASKED',) if the antimask has been applied. * ('FLOAT', digits, reference) for any floating-point compression performed. * ('BOOL', shape, size) if packbits plus BZ2 compression was performed. * ('INT', shape) if BZ2 compression of integers was performed. - Note: - For floating-point arrays using lossy compression methods (e.g., when digits < 16 - or reference != 'double'), the round-trip values may differ slightly from the - original due to compression precision limits. Use 'double' precision with 'fpzip' - reference for lossless compression. + Returns: + dict[str, Any]: The encoded state of this object. + + Notes: + For floating-point arrays using lossy compression, which is any `digits` setting + other than "double", the round-trip values may differ slightly from the original + due to compression precision limits. Use "double" for lossless compression. """ # Start with a shallow clone; save derivatives for later @@ -919,16 +1011,17 @@ def __setstate__(self, state): """Restore the object state from a pickled dictionary. This method decodes the mask and values from their encoded forms (as stored by - __getstate__), handles version compatibility, and restores the object to its - original state. - - Note: For floating-point arrays using lossy compression methods (e.g., when digits < - 16 or reference != 'double'), the restored values may differ slightly from the - original due to compression precision limits. Use 'double' precision with 'fpzip' - reference for lossless compression. + :meth:`~polymath.Qube.__getstate__`), handles renamed attributes from earlier pickle + formats, and restores the object to its original state. Parameters: - state (dict): The state dictionary as returned by __getstate__(). + state (dict[str, Any]): The state dictionary as returned by + :meth:`~polymath.Qube.__getstate__`. + + Notes: + For floating-point arrays using lossy compression, which is any `digits` setting + other than "double", the restored values may differ slightly from the original + due to compression precision limits. Use "double" for lossless compression. """ # Handle renamed keys diff --git a/src/polymath/extensions/readonly_ops.py b/src/polymath/extensions/readonly_ops.py index 5ddf20b..35fe268 100644 --- a/src/polymath/extensions/readonly_ops.py +++ b/src/polymath/extensions/readonly_ops.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/readonly_ops.py: Read-only/read-write and copying operations ########################################################################################## +"""Read-only and read-write state, and the copying of PolyMath objects. + +A read-only object is protected from modification as far as Python allows, which makes it +safe to share the memory underlying it. These functions convert an object to read-only, +copy an object into a writable one, and assert that an object may be modified. +""" import numpy as np from polymath.qube import Qube @@ -14,6 +20,12 @@ def _array_is_readonly(arg): """True if the argument is a read-only NumPy ndarray. False means that it is either a writable array or a scalar. + + Parameters: + arg (Any): The object to test. + + Returns: + bool: True if `arg` is a NumPy array that is not writable. """ if not isinstance(arg, np.ndarray): @@ -24,7 +36,14 @@ def _array_is_readonly(arg): @staticmethod def _array_to_readonly(arg): - """Make the given argument read-only if it is a NumPy ndarray; then return it.""" + """Make the given argument read-only if it is a NumPy ndarray; then return it. + + Parameters: + arg (Any): The object to make read-only. + + Returns: + Any: `arg`, with its writable flag cleared if it is a NumPy array. + """ if not isinstance(arg, np.ndarray): return arg @@ -37,12 +56,11 @@ def as_readonly(self, *, recursive=True): """Convert this object to read-only. It is modified in place and returned. If this object is already read-only, it is returned as is. Otherwise, the internal - _values and _mask arrays are modified as necessary. Once this happens, the - internal arrays will also cease to be writable in any other object that shares - them. + value and mask arrays are modified as necessary. Once this happens, the internal + arrays will also cease to be writable in any other object that shares them. - Note that `as_readonly()` cannot be undone. Use `copy()` to create a writable copy - of a readonly object. + Note that :meth:`~polymath.Qube.as_readonly` cannot be undone. Use + :meth:`~polymath.Qube.copy` to create a writable copy of a read-only object. Parameters: recursive (bool, optional): True also to convert the derivatives to read-only; @@ -78,16 +96,16 @@ def as_readonly(self, *, recursive=True): def match_readonly(self, arg): - """Convert the read-only status of this object equal to that of another. + """Make the read-only status of this object match that of another. Parameters: - arg (Qube): An existing Qube subclass. + arg (Qube): The object whose read-only status is to be matched. Returns: - Qube: This object converted to read-only. + Qube: This object, converted to read-only if `arg` is read-only. Raises: - ValueError: If this object is read-only but the `arg` is not. + ValueError: If this object is read-only but `arg` is not. """ if arg._readonly: @@ -99,14 +117,16 @@ def match_readonly(self, arg): def require_writeable(self, force=False): - """Ensure that this object is writeable. + """Ensure that this object is writable. + + :meth:`~polymath.Qube.require_writable` is an alternative name for this method. Parameters: force (bool, optional): True to return a new copy if this object is read-only; - otherwise, if this object is not writeable, raise a ValueError. + otherwise, if this object is not writable, raise a ValueError. Returns: - Qube: This object if already writeable; otherwise a new writeable copy. + Qube: This object if already writable; otherwise a new writable copy. Raises: ValueError: If this object is read-only but `force` is False. @@ -114,10 +134,10 @@ def require_writeable(self, force=False): if self._readonly: if force: - return self.copy(recursive=True, readonly=True) + return self.copy(recursive=True, readonly=False) raise ValueError(f'{type(self).__name__} object is read-only') - # Sometimes the array is writeable but a shared mask is not + # Sometimes the array is writable but a shared mask is not if np.shape(self._mask) and not self._mask.flags['WRITEABLE']: self.remask(self._mask.copy()) @@ -130,16 +150,16 @@ def require_writeable(self, force=False): def require_writable(self, force=False): - """Ensure that this object is writeable. + """Ensure that this object is writable. - DEPRECATED NAME; use require_writeable(). + This is an alternative name for :meth:`~polymath.Qube.require_writeable`. Parameters: force (bool, optional): True to return a new copy if this object is read-only; - otherwise, if this object is not writeable, raise a ValueError. + otherwise, if this object is not writable, raise a ValueError. Returns: - Qube: This object if already writeable; otherwise a new writeable copy. + Qube: This object if already writable; otherwise a new writable copy. Raises: ValueError: If this object is read-only but `force` is False. @@ -152,24 +172,25 @@ def copy(self, *, recursive=True, readonly=False): """Deep copy operation with additional options. Parameters: - recursive (bool, optional): True to copy the derivatives; False, to return an + recursive (bool, optional): True to copy the derivatives; False to return an object without derivatives. - readonly (bool, optional): True to return a read-only copy, or this object if - it is already read-only. Otherwise, this return is guaranteed to be an - entirely new copy, independent of this object and suitable for + readonly (bool, optional): True to return a read-only copy; if this object is + already read-only, the return is a shallow copy, which shares this object's + arrays rather than duplicating them. Otherwise, this return is guaranteed to + be an entirely new copy, independent of this object and suitable for modification. Returns: Qube: A copy of this object. """ + # Copying a readonly object is easy, because nothing in it can be modified + if self._readonly and readonly: + return self.clone(recursive=recursive) + # Create a shallow copy obj = self.clone(recursive=False) - # Copying a readonly object is easy - if self._readonly and readonly: - return obj - # Copy the values if self._is_array: obj._values = self._values.copy() @@ -200,7 +221,11 @@ def copy(self, *, recursive=True, readonly=False): # Python-standard copy function def __copy__(self): - """An independent, writeable copy of this object.""" + """An independent, writable copy of this object. + + Returns: + Qube: A deep copy of this object, writable. + """ return self.copy(recursive=True, readonly=False) diff --git a/src/polymath/extensions/shaper.py b/src/polymath/extensions/shaper.py index adf5bfd..a33fa70 100644 --- a/src/polymath/extensions/shaper.py +++ b/src/polymath/extensions/shaper.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/extensions/shaper.py: re-shaping operations ########################################################################################## +"""Re-shaping of the leading array axes of a PolyMath object. + +These functions reshape, flatten, roll, move, and swap the leading axes of an object, +leaving its items untouched, and stack several objects into one along a new leading axis. +Each operation is applied to the object's derivatives as well. +""" import math import numpy as np @@ -13,15 +19,14 @@ def reshape(self, shape, *, recursive=True): """A shallow copy of the object with a new leading shape. Parameters: - shape (tuple or int): A tuple defining the new leading shape. A value of -1 can - appear at one location in the new shape, and the size of that shape will be - determined based on this object's size. + shape (tuple[int, ...] | int): A tuple defining the new leading shape. A value of + -1 can appear at one location in the new shape, and the size of that axis is + then inferred from this object's size. recursive (bool, optional): True to apply the same shape to the derivatives. Otherwise, derivatives are deleted from the returned object. Returns: - Qube: A shallow copy with the new shape. If the shape is unchanged, this object is - returned without modification. The read-only status is preserved. + Qube: A shallow copy with the new shape. The read-only status is preserved. Raises: ValueError: If the new shape is incompatible with the current shape. @@ -118,8 +123,8 @@ def roll_axis(self, axis, start=0, *, recursive=True, rank=None): start (int, optional): The axis will be rolled to fall in front of this axis. recursive (bool, optional): True to perform the same axis roll on the derivatives. Otherwise, derivatives are deleted from the returned object. - rank (int, optional): Rank to assume for the object, which could be larger than - len(self.shape) because of broadcasting. + rank (int | None, optional): Rank to assume for the object, which could be larger + than len(self.shape) because of broadcasting. Returns: Qube: A shallow copy with the axis rolled to the new position. @@ -173,12 +178,12 @@ def move_axis(self, source, destination, *, recursive=True, rank=None): """A shallow copy of the object with the specified axis moved to a new position. Parameters: - source (int or tuple): Axis to move or tuple of axes to move. - destination (int or tuple): Destination of moved axis or axes. + source (int | tuple[int, ...]): Axis to move or tuple of axes to move. + destination (int | tuple[int, ...]): Destination of moved axis or axes. recursive (bool, optional): True to perform the same axis move on the derivatives. Otherwise, derivatives are deleted from the returned object. - rank (int, optional): Rank to assume for the object, which could be larger than - len(self.shape) because of broadcasting. + rank (int | None, optional): Rank to assume for the object, which could be larger + than len(self.shape) because of broadcasting. Returns: Qube: A shallow copy with the specified axis moved to the new position. @@ -237,20 +242,20 @@ def stack(*args, recursive=True): """Stack objects into one with a new leading axis. Parameters: - *args: Any number of Scalars or arguments that can be casted to Scalars. They need - not have the same shape, but it must be possible to cast them to the same - shape. A value of None is converted to a zero-valued Scalar that matches the - denominator shape of the other arguments. + *args (QubeLike | None): Any number of PolyMath objects or values that can be + converted to them. They need not have the same shape, but it must be possible + to broadcast them to the same shape. A value of None is converted to a + zero-valued object that matches the item shape of the other arguments. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives - found amongst the scalars. + found amongst the arguments. Returns: - Qube: A stacked object with a new leading axis. + Qube: A stacked object with a new leading axis, of the same class as the + arguments. Raises: - TypeError: If an unexpected keyword argument is provided. - ValueError: If the arguments have incompatible denominators. + ValueError: If the arguments have incompatible denominators or units. """ args = list(args) diff --git a/src/polymath/extensions/shrinker.py b/src/polymath/extensions/shrinker.py index 431d76d..27953ac 100644 --- a/src/polymath/extensions/shrinker.py +++ b/src/polymath/extensions/shrinker.py @@ -1,6 +1,13 @@ -################################################################################ +########################################################################################## # polymath/extensions/shrinker.py: shrink and unshrink operations -################################################################################ +########################################################################################## +"""Compression of a PolyMath object down to its unmasked elements. + +Shrinking replaces an object with a 1-D object holding only the elements selected by an +antimask, which can make a subsequent calculation far cheaper when most elements are +masked. Unshrinking restores the original shape afterward, masking everything the antimask +excluded. +""" import numpy as np from polymath.qube import Qube @@ -12,7 +19,7 @@ def shrink(self, antimask): """A 1-D version of this object, containing only the samples in the antimask provided. The antimask array value of True indicates that an element should be included; False - means that is should be discarded. A scalar value of True or False applies to the + means that it should be discarded. A scalar value of True or False applies to the entire object. The antimask must be broadcastable to the rightmost dimensions of the object's shape. @@ -20,12 +27,20 @@ def shrink(self, antimask): or be broadcastable to (4, 5). A 1-D antimask of shape (4,) cannot be used directly with a 2-D object of shape (4, 5). - The purpose is to speed up calculations by first eliminating all the objects that are - masked. Any calculation involving un-shrunken objects should produce the same result - if the same objects are all shrunken by a common antimask first, the calculation is - performed, and then the result is un-shrunken afterward. + The purpose is to speed up calculations by first eliminating all the elements that + are masked. Any calculation involving un-shrunken objects should produce the same + result if the same objects are all shrunken by a common antimask first, the + calculation is performed, and then the result is un-shrunken afterward. Shrunken objects are always converted to read-only. + + Parameters: + antimask (BooleanLike): True where an element is to be included; False where it is + to be discarded. A scalar True or False applies to the entire object. + + Returns: + Qube: A shrunken, read-only version of this object, of the same class as `self`. + If no shrinking is needed, `self` is returned. """ # For testing only... @@ -115,11 +130,11 @@ def unshrink(self, antimask, shape=()): values in the antimask. Parameters: - antimask (array-like): The antimask to apply. - shape (tuple, optional): The shape of the returned object in the cases where it - cannot be reconstructed, described below. The result is then entirely masked, - holding default values rather than the original ones. Normally, the rightmost - axes of the returned object match those of the antimask. + antimask (BooleanLike): The antimask to apply. + shape (tuple[int, ...], optional): The shape of the returned object in the cases + where it cannot be reconstructed, described below. The result is then entirely + masked, holding default values rather than the original ones. Normally, the + rightmost axes of the returned object match those of the antimask. Returns: Qube: The un-shrunken object, which will be read-only. @@ -128,10 +143,10 @@ def unshrink(self, antimask, shape=()): The original shape cannot always be recovered. An object that was entirely masked, or an antimask that is a single False, shrinks to one value, which leaves nothing to say what the leading axes were; and this method is often reached through a - chain of calculations rather than directly from :meth:`~Qube.shrink`, so the - original is not necessarily still to hand. In those cases the result is shapeless - unless `shape` says otherwise. Supply `shape` whenever the un-shrunken shape - matters to the caller. + chain of calculations rather than directly from :meth:`~polymath.Qube.shrink`, so + the original is not necessarily still to hand. In those cases the result is + shapeless unless `shape` says otherwise. Supply `shape` whenever the un-shrunken + shape matters to the caller. """ # For testing only... @@ -200,4 +215,4 @@ def unshrink(self, antimask, shape=()): return obj -################################################################################ +########################################################################################## diff --git a/src/polymath/extensions/tvl.py b/src/polymath/extensions/tvl.py index 7663929..15e4fe3 100644 --- a/src/polymath/extensions/tvl.py +++ b/src/polymath/extensions/tvl.py @@ -1,6 +1,13 @@ -################################################################################ +########################################################################################## # polymath/extensions/tvl.py: Three-valued logic operations -################################################################################ +########################################################################################## +"""Three-valued logic, in which a masked value means "maybe". + +The ordinary comparison operators treat a masked value as undefined and return a masked +result. The operations here treat it as a third truth value instead, so that a result is +True or False whenever the masked elements cannot affect the answer, and masked only when +they can. +""" import numpy as np from polymath.qube import Qube @@ -21,16 +28,17 @@ def tvl_and(self, arg, builtins=None, masked=None): * Masked and Masked = Masked Parameters: - arg (Qube or bool): The right-hand operand for the AND operation. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. + arg (BooleanLike): The right-hand operand for the AND operation. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - (Boolean or bool): The result of the three-valued logic "and" operation. When the + Boolean | bool: The result of the three-valued logic "and" operation. When the result is masked, the underlying boolean value may be either True or False, and the mask indicates indeterminacy. """ @@ -116,16 +124,17 @@ def tvl_or(self, arg, builtins=None, masked=None): * Masked or Masked = Masked Parameters: - arg (Qube or bool): The right-hand operand for the OR operation. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. + arg (BooleanLike): The right-hand operand for the OR operation. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - (Boolean or bool): The result of the three-valued logic "or" operation. When the + Boolean | bool: The result of the three-valued logic "or" operation. When the result is masked, the underlying boolean value may be either True or False, and the mask indicates indeterminacy. """ @@ -207,23 +216,24 @@ def tvl_any(self, axis=None, builtins=None, masked=None): * otherwise, Masked. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The - any operation is performed across these axes, leaving any remaining - axes in the returned value. If None (the default), then the any - operation is performed across all axes of the object, reducing to a - scalar result. When axis is specified, the result shape is the original - shape with the specified axes removed. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of axes. + The any operation is performed across these axes, leaving any remaining axes + in the returned value. If None (the default), then the any operation is + performed across all axes of the object, reducing to a scalar result. When + axis is specified, the result shape is the original shape with the specified + axes removed. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - (Boolean or bool): The result of the three-valued logic "any" operation. The - result is masked if any values along the specified axes are masked, unless - an unmasked True value is found. + Boolean | bool: The result of the three-valued logic "any" operation. The result + is masked if any values along the specified axes are masked, unless an unmasked + True value is found. Examples: >>> a = Boolean([True, False, True]) @@ -267,28 +277,29 @@ def tvl_all(self, axis=None, builtins=None, masked=None): Masked values are treated as indeterminate rather than being ignored. These are the rules: - * True if and only if all the items are True and unmasked. - * False if any unmasked value is False. + * True if and only if all the items are True and unmasked; + * False if any unmasked value is False; * otherwise, Masked. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The - all operation is performed across these axes, leaving any remaining - axes in the returned value. If None (the default), then the all - operation is performed across all axes of the object, reducing to a - scalar result. When axis is specified, the result shape is the original - shape with the specified axes removed. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). - masked (bool, optional): The value to return if builtins is True but the returned - value is masked. Default is to return a masked value instead of a builtin - type. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of axes. + The all operation is performed across these axes, leaving any remaining axes + in the returned value. If None (the default), then the all operation is + performed across all axes of the object, reducing to a scalar result. When + axis is specified, the result shape is the original shape with the specified + axes removed. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + masked (bool | None, optional): The value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - (Boolean or bool): The result of the three-valued logic "all" operation. The - result is masked if any values along the specified axes are masked, unless - an unmasked False value is found. + Boolean | bool: The result of the three-valued logic "all" operation. The result + is masked if any values along the specified axes are masked, unless an unmasked + False value is found. Examples: >>> a = Boolean([True, True, True]) @@ -334,16 +345,17 @@ def tvl_eq(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or bool): The right-hand operand for the equality comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the equality comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic equality comparison. - When the result is masked, the underlying boolean value may be either True or - False, and the mask indicates indeterminacy. The `builtins` parameter affects - the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic equality comparison. When the + result is masked, the underlying boolean value may be either True or False, and + the mask indicates indeterminacy. The `builtins` parameter affects the return type + but not the masking behavior. """ return self._tvl_op(arg, (self == arg), builtins=builtins) @@ -356,16 +368,17 @@ def tvl_ne(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or bool): The right-hand operand for the inequality comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the inequality comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic inequality comparison. - When the result is masked, the underlying boolean value may be either True or - False, and the mask indicates indeterminacy. The `builtins` parameter affects - the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic inequality comparison. When + the result is masked, the underlying boolean value may be either True or False, + and the mask indicates indeterminacy. The `builtins` parameter affects the return + type but not the masking behavior. """ return self._tvl_op(arg, (self != arg), builtins=builtins) @@ -378,16 +391,17 @@ def tvl_lt(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or numbers.Real): The right-hand operand for the comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic "less than" comparison. - When the result is masked, the underlying boolean value may be either True or - False, and the mask indicates indeterminacy. The `builtins` parameter affects - the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic "less than" comparison. When + the result is masked, the underlying boolean value may be either True or False, + and the mask indicates indeterminacy. The `builtins` parameter affects the return + type but not the masking behavior. """ return self._tvl_op(arg, (self < arg), builtins=builtins) @@ -400,16 +414,17 @@ def tvl_gt(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or numbers.Real): The right-hand operand for the comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic "greater than" - comparison. When the result is masked, the underlying boolean value may be - either True or False, and the mask indicates indeterminacy. The `builtins` - parameter affects the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic "greater than" comparison. + When the result is masked, the underlying boolean value may be either True or + False, and the mask indicates indeterminacy. The `builtins` parameter affects the + return type but not the masking behavior. """ return self._tvl_op(arg, (self > arg), builtins=builtins) @@ -422,16 +437,17 @@ def tvl_le(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or numbers.Real): The right-hand operand for the comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic "less than or equal - to" comparison. When the result is masked, the underlying boolean value may be - either True or False, and the mask indicates indeterminacy. The `builtins` - parameter affects the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic "less than or equal to" + comparison. When the result is masked, the underlying boolean value may be either + True or False, and the mask indicates indeterminacy. The `builtins` parameter + affects the return type but not the masking behavior. """ return self._tvl_op(arg, (self <= arg), builtins=builtins) @@ -444,16 +460,17 @@ def tvl_ge(self, arg, builtins=None): value is masked. Parameters: - arg (Qube or numbers.Real): The right-hand operand for the comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic "greater than or - equal to" comparison. When the result is masked, the underlying boolean value - may be either True or False, and the mask indicates indeterminacy. The - `builtins` parameter affects the return type but not the masking behavior. + Boolean | bool: The result of the three-valued logic "greater than or equal to" + comparison. When the result is masked, the underlying boolean value may be either + True or False, and the mask indicates indeterminacy. The `builtins` parameter + affects the return type but not the masking behavior. """ return self._tvl_op(arg, (self >= arg), builtins=builtins) @@ -466,14 +483,15 @@ def _tvl_op(self, arg, comparison, builtins=None): value is masked. Parameters: - arg (Qube or numbers.Real): The right-hand operand for the operation. - comparison (Qube or bool): The result of the boolean comparison. - builtins (bool, optional): If True and the result is a single unmasked scalar, the - result is returned as a Python boolean instead of as an instance of Boolean. - Default is to use the global setting defined by Qube.prefer_builtins(). + arg (QubeLike): The right-hand operand for the operation. + comparison (Boolean | bool): The result of the boolean comparison. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python boolean instead of as an instance + of Boolean. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. Returns: - (Boolean or bool): The result of the three-valued logic operation. + Boolean | bool: The result of the three-valued logic operation. """ # Return a Python bool if appropriate @@ -496,4 +514,4 @@ def _tvl_op(self, arg, comparison, builtins=None): comparison._set_mask(Qube.or_(self._mask, arg_mask)) return comparison -################################################################################ +########################################################################################## diff --git a/src/polymath/extensions/unit_ops.py b/src/polymath/extensions/unit_ops.py index a1f66d8..5dee10a 100644 --- a/src/polymath/extensions/unit_ops.py +++ b/src/polymath/extensions/unit_ops.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/unit_ops.py: Unit operations ########################################################################################## +"""Attachment and interpretation of the units of a PolyMath object. + +The values inside an object are always held in standard units of kilometers, seconds, and +radians. These functions attach a :class:`~polymath.Unit` to an object, remove it, convert +the object's values into it for display, and confirm that the units of two operands can be +combined. +""" from polymath.qube import Qube from polymath.unit import Unit @@ -12,12 +19,15 @@ def set_unit(self, unit, *, override=False): """Set the unit of this object. Parameters: - unit (Unit or None): The new unit. + unit (Unit | str | None): The new unit, given as a Unit, a standard unit name, or + None for no unit. override (bool, optional): If True, the unit can be modified on a read-only object. Raises: - ValueError: If this object is read-only and `override` is False. + TypeError: If this class does not permit units and `unit` is not unitless. + ValueError: If this object is read-only and `override` is False, or if `unit` is + not compatible with the current unit of this object. """ if not self._UNITS_OK: @@ -38,12 +48,12 @@ def set_unit(self, unit, *, override=False): def without_unit(self, *, recursive=True): """A shallow copy of this object without units. - A read-only object remains read-only. If recursive is True, derivatives are also + A read-only object remains read-only. If `recursive` is True, derivatives are also stripped of their units. Parameters: - recursive (bool, optional): True to include derivatives with their units - stripped; False to omit all derivatives. + recursive (bool, optional): True to include derivatives with their units stripped; + False to omit all derivatives. Returns: Qube: A shallow copy of this object with the unit stripped. @@ -67,20 +77,20 @@ def without_unit(self, *, recursive=True): def into_unit(self, *, recursive=False): """The values property of this object, converted to its unit. - This method converts values from standard units (kilometers, seconds, radians) - to this object's specified unit. For example, if the object has unit=Unit.M - (meters) and the internal values are in kilometers (standard units), this - method converts from km to m by multiplying by 1000. + This method converts values from standard units (kilometers, seconds, radians) to + this object's specified unit. For example, if the object has ``unit=Unit.M`` (meters) + and the internal values are in kilometers (standard units), this method converts from + km to m by multiplying by 1000. Parameters: recursive (bool, optional): If True, also return the derivatives converted to their units. Returns: - (numpy.ndarray, float, int, bool, or tuple): The values attribute of this - object, converted from standard units to this object's unit. If `recursive` - is True, it returns a tuple (`values`, `derivs`), where `derivs` is a - dictionary of the derivative values converted to their units. + numpy.ndarray | float | int | bool | tuple: The values attribute of this object, + converted from standard units to this object's unit. If `recursive` is True, it + returns a tuple (`values`, `derivs`), where `derivs` is a dictionary of the + derivative values converted to their units. Examples: >>> a = Scalar([1.0, 2.0, 3.0], unit=Unit.M) # values in km (standard) @@ -103,16 +113,16 @@ def into_unit(self, *, recursive=False): def confirm_unit(self, unit): - """Raises a ValueError if the unit is not compatible with this object. + """Raise a ValueError if the unit is not compatible with this object. Parameters: - unit (Unit or None): The new unit. + unit (Unit | None): The unit to check. Returns: Qube: This object. Raises: - ValueError: If this object has a unit that are incompatible with the new unit. + ValueError: If this object has a unit that is incompatible with `unit`. """ if not Unit.can_match(self._unit, unit): @@ -123,7 +133,11 @@ def confirm_unit(self, unit): def is_unitless(self): - """True if this object is unitless.""" + """True if this object is unitless. + + Returns: + bool: True if this object has no unit or a unitless unit. + """ return Unit.is_unitless(self._unit) @@ -132,7 +146,7 @@ def _require_unitless(self, op=''): """Raise a ValueError if this object is not unitless. Parameters: - info (str, optional): An info string to embed into the error message. + op (str, optional): Operation name to embed into the error message. Raises: ValueError: If units are present. @@ -145,8 +159,7 @@ def _require_unitless(self, op=''): def _require_angle(self, op=''): - """Raise a ValueError if this object is not either unitless or has a dimension of - angle. + """Raise a ValueError if this object is neither unitless nor an angle. Parameters: op (str, optional): Operation name to embed into the error message. @@ -165,8 +178,12 @@ def _require_compatible_units(self, arg, op=''): """Raise a ValueError if these objects do not have compatible units. Parameters: + arg (QubeLike): The object whose unit must be compatible with this object's unit. op (str, optional): Operation name to embed into the error message. + Returns: + bool: True if the units are compatible. + Raises: ValueError: If units are not compatible. """ diff --git a/src/polymath/extensions/vector_ops.py b/src/polymath/extensions/vector_ops.py index ed240cf..cce744d 100644 --- a/src/polymath/extensions/vector_ops.py +++ b/src/polymath/extensions/vector_ops.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/extensions/vector_ops.py: vector operations ########################################################################################## +"""Vector and matrix products of PolyMath objects. + +These functions implement the dot, cross, and outer products, the norm and its square, and +the root-mean-square, each operating on a chosen pair of item axes. They are defined here +rather than on :class:`~polymath.Vector` because they apply to any object whose item axes +have suitable lengths. +""" import math import numpy as np @@ -18,10 +25,10 @@ def _mean_or_sum(arg, axis=None, *, recursive=True, _combine_as_mean=False): Parameters: arg (Qube): The object for which to calculate the mean or sum. - axis (int or tuple, optional): An integer axis or a tuple of axes. The mean is - determined across these axes, leaving any remaining axes in the returned - value. If None (the default), then the mean is performed across all axes of - the object. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of axes. + The mean is determined across these axes, leaving any remaining axes in the + returned value. If None (the default), then the mean is performed across all + axes of the object. recursive (bool, optional): True to include derivatives in the returned object. _combine_as_mean (bool, optional): True to combine as a mean; False to combine as a sum. @@ -111,7 +118,7 @@ def _mean_or_sum(arg, axis=None, *, recursive=True, _combine_as_mean=False): drank=arg._drank, unit=arg._unit, example=arg) # Cast to the proper class - obj = obj.cast(type(arg)) + obj = obj.cast(classes=type(arg)) # Handle derivatives if recursive and arg._derivs: @@ -130,7 +137,7 @@ def _check_axis(arg, axis, op): Parameters: arg (Qube): The object to check the axis for. - axis: The axis to validate. + axis (int | tuple[int, ...] | None): The axis to validate. op (str): The operation name for error messages. Raises: @@ -169,7 +176,8 @@ def _zero_sized_result(self, axis): """A zero-sized result obtained by collapsing one or more axes. Parameters: - axis (int or tuple, optional): The axis or axes to collapse. + axis (int | tuple[int, ...] | None): The axis or axes to collapse; None to + collapse every axis. Returns: Qube: A zero-sized result with the specified axes collapsed. @@ -207,9 +215,9 @@ def dot(arg1, arg2, axis1=-1, axis2=0, *, classes=(), recursive=True): arg2 (Qube): The second operand as a subclass of Qube. axis1 (int, optional): The item axis of arg1 for the dot product. Default is -1. axis2 (int, optional): The item axis of arg2 for the dot product. Default is 0. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: @@ -296,7 +304,7 @@ def dot(arg1, arg2, axis1=-1, axis2=0, *, classes=(), recursive=True): nrank=new_nrank, drank=new_drank, unit=Unit.mul_units(arg1._unit, arg2._unit), example=arg1) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and (arg1._derivs or arg2._derivs): @@ -336,22 +344,22 @@ def norm(arg, axis=-1, *, classes=(), recursive=True): Parameters: arg (Qube): The object for which to calculate the norm. axis (int, optional): The numerator axis for the norm. Defaults to -1. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: Qube: The norm of the object along the specified axis. Raises: - ValueError: If the object has denominators or if the axis is out of - range. + ValueError: If the object has denominators or if the axis is out of range. Examples: - For a Vector with shape (2, 3) and numer (2,): - - axis=-1 (default) → result shape (2, 3), numer () - - axis=0 → result shape (2, 3), numer () + For a Vector with shape (2, 3) and numer (2,):: + + axis=-1 (default) -> result shape (2, 3), numer () + axis=0 -> result shape (2, 3), numer () """ arg._disallow_denom('norm()') @@ -374,7 +382,7 @@ def norm(arg, axis=-1, *, classes=(), recursive=True): # Construct the object and cast obj = Qube._new_from_parts(new_values, arg._mask, nrank=arg._nrank-1, drank=arg._drank, unit=arg._unit, example=arg) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and arg._derivs: @@ -397,11 +405,11 @@ def norm_sq(arg, axis=-1, *, classes=(), recursive=True): arg.norm_sq(...). Parameters: - arg: The object for which to calculate the norm-squared. + arg (Qube): The object for which to calculate the norm-squared. axis (int, optional): The item axis for the norm. Default is -1. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: @@ -411,9 +419,10 @@ def norm_sq(arg, axis=-1, *, classes=(), recursive=True): ValueError: If the object has denominators or if the axis is out of range. Examples: - For a Vector with shape (2, 3) and numer (2,): - - axis=-1 (default) → result shape (2, 3), numer () - - axis=0 → result shape (2, 3), numer () + For a Vector with shape (2, 3) and numer (2,):: + + axis=-1 (default) -> result shape (2, 3), numer () + axis=0 -> result shape (2, 3), numer () """ arg._disallow_denom('norm_sq()') @@ -437,7 +446,7 @@ def norm_sq(arg, axis=-1, *, classes=(), recursive=True): obj = Qube._new_from_parts(new_values, arg._mask, nrank=arg._nrank-1, drank=arg._drank, unit=Unit.mul_units(arg._unit, arg._unit), example=arg) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and arg._derivs: @@ -465,17 +474,17 @@ def cross(arg1, arg2, axis1=-1, axis2=0, *, classes=(), recursive=True): arg2 (Qube): The second operand. axis1 (int, optional): The item axis of the first object. Defaults to -1. axis2 (int, optional): The item axis of the second object. Defaults to 0. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: Qube: The cross product of the two objects. Raises: - ValueError: If both objects have denominators, if axes are out of range, - or if axis lengths are incompatible. + ValueError: If both objects have denominators, if axes are out of range, or if + axis lengths are incompatible. """ # At most one object can have a denominator. @@ -541,7 +550,7 @@ def cross(arg1, arg2, axis1=-1, axis2=0, *, classes=(), recursive=True): nrank=new_nrank, drank=new_drank, unit=Unit.mul_units(arg1._unit, arg2._unit), example=arg1) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and (arg1._derivs or arg2._derivs): @@ -624,7 +633,7 @@ def _cross_2x2(a, b): @staticmethod -def outer(arg1, arg2, classes=(), recursive=True): +def outer(arg1, arg2, *, classes=(), recursive=True): """Calculate the outer product of two objects. The item shape of the returned object is obtained by concatenating the two @@ -637,9 +646,9 @@ def outer(arg1, arg2, classes=(), recursive=True): Parameters: arg1 (Qube): The first operand. arg2 (Qube): The second operand. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: @@ -674,7 +683,7 @@ def outer(arg1, arg2, classes=(), recursive=True): nrank=new_nrank, drank=new_drank, unit=Unit.mul_units(arg1._unit, arg2._unit), example=arg1) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and (arg1._derivs or arg2._derivs): @@ -704,7 +713,7 @@ def outer(arg1, arg2, classes=(), recursive=True): @staticmethod -def as_diagonal(arg, axis, classes=(), recursive=True): +def as_diagonal(arg, axis, *, classes=(), recursive=True): """A copy with one axis converted to a diagonal across two. Note: This is a static method. Call it as Qube.as_diagonal(arg, axis, ...) rather than @@ -713,9 +722,9 @@ def as_diagonal(arg, axis, classes=(), recursive=True): Parameters: arg (Qube): The object to convert. axis (int): The item axis to convert to two. - classes (type, list, or tuple, optional): The class of the object returned. If a - list is provided, the object will be an instance of the first suitable class - in the list. Otherwise, a generic Qube object will be returned. + classes (type | list[type] | tuple[type, ...], optional): The class of the object + returned. If a list is provided, the object will be an instance of the first + suitable class in the list. Otherwise, a generic Qube object will be returned. recursive (bool, optional): True to include derivatives in the returned object. Returns: @@ -755,12 +764,13 @@ def as_diagonal(arg, axis, classes=(), recursive=True): # Construct and cast obj = Qube._new_from_parts(new_values, arg._mask, nrank=arg._nrank + 1, drank=arg._drank, unit=arg._unit, example=arg) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Diagonalize the derivatives if necessary if recursive: for key, deriv in arg._derivs.items(): - obj.insert_deriv(key, Qube.as_diagonal(deriv, axis, classes, False)) + obj.insert_deriv(key, Qube.as_diagonal(deriv, axis, classes=classes, + recursive=False)) return obj @@ -768,9 +778,10 @@ def as_diagonal(arg, axis, classes=(), recursive=True): def rms(self): """Calculate the root-mean-square values of all items as a Scalar. - The RMS is computed across all item dimensions (numerator dimensions) for each - array element. For a Vector with shape (n,) and numer (3,), this computes - sqrt(sum(vals^2) / 3) for each of the n elements. + The RMS is computed across all item dimensions, numerator and denominator alike, for + each array element. For a Vector with shape (n,) and numer (3,), this computes + sqrt(sum(vals**2) / 3) for each of the n elements. The mask is preserved; the unit and + derivatives are not. Useful for looking at the overall magnitude of the differences between two objects. @@ -783,4 +794,4 @@ def rms(self): return Qube._SCALAR_CLASS(np.sqrt(sum_sq / self.isize), self._mask) -################################################################################ +########################################################################################## diff --git a/src/polymath/matrix.py b/src/polymath/matrix.py index fdb21c8..59d52ff 100755 --- a/src/polymath/matrix.py +++ b/src/polymath/matrix.py @@ -1,6 +1,13 @@ ########################################################################################## -# polymath/matrix.py: Matrix subclass ofse PolyMath base class +# polymath/matrix.py: Matrix subclass of the PolyMath base class ########################################################################################## +"""The :class:`~polymath.Matrix` subclass, representing arbitrary 2-D matrices. + +A Matrix has a numerator shape of ``(m, n)``, so each of its items is a two-dimensional +array. This class provides the matrix algebra: transposes, inverses, determinants, and the +solution of linear systems, along with the methods that extract rows and columns as +:class:`~polymath.Vector` objects. +""" import math import numpy as np @@ -39,7 +46,7 @@ def as_matrix(arg, *, recursive=True): """Convert the argument to a Matrix if possible. Parameters: - arg: The object to convert to a Matrix. + arg (MatrixLike): The object to convert to a Matrix. recursive (bool, optional): True to include derivatives in the result. Returns: @@ -53,33 +60,37 @@ def as_matrix(arg, *, recursive=True): # Convert a Vector with drank=1 to a Matrix if isinstance(arg, Vector) and arg._drank == 1: - return arg.join_items([Matrix]) + return arg.join_items(classes=[Matrix]) arg = Matrix(arg._values, arg._mask, example=arg) return arg if recursive else arg.wod return Matrix(arg) - def row_vector(self, row, *, recursive=True, classes=(Vector3, Vector)): + def row_vector(self, row, *, recursive=True, classes=()): """The selected row of a Matrix as a Vector. If the Matrix is M x N, then this will return a Vector of length N. By default, if N == 3, it will return a Vector3 object instead. Parameters: - row: Index of the row to return. + row (int): Index of the row to return. recursive (bool, optional): True to return corresponding vectors of derivatives. - classes (tuple, optional): A list of classes; an instance of the first - suitable class is returned. + classes (type | list[type] | tuple[type, ...], optional): A list of classes; + an instance of the first suitable class is returned. Default is + [:class:`~polymath.Vector3`, :class:`~polymath.Vector`]. Returns: - Vector or Vector3: The selected row as a vector. + Qube: The selected row as an object in one of the specified `classes`. """ + if not classes: + classes = [Vector3, Vector] + return self.extract_numer(0, row, recursive=recursive, classes=classes) - def row_vectors(self, *, recursive=True, classes=(Vector3, Vector)): + def row_vectors(self, *, recursive=True, classes=()): """A tuple of Vector objects, one for each row of this Matrix. If the Matrix is M x N, then this will return M Vectors of length N. By default, @@ -88,13 +99,17 @@ def row_vectors(self, *, recursive=True, classes=(Vector3, Vector)): Parameters: recursive (bool, optional): True to return corresponding vectors of derivatives. - classes (tuple, optional): A list of classes; instances of the first - suitable class are returned. + classes (type | list[type] | tuple[type, ...], optional): A list of classes; + instances of the first suitable class are returned. Default is + [:class:`~polymath.Vector3`, :class:`~polymath.Vector`]. Returns: - tuple: A tuple of Vector objects, one for each row. + tuple[Qube, ...]: A tuple of objects in one of the specified `classes`. """ + if not classes: + classes = [Vector3, Vector] + vectors = [] for row in range(self._numer[0]): vectors.append(self.extract_numer(0, row, recursive=recursive, @@ -102,26 +117,30 @@ def row_vectors(self, *, recursive=True, classes=(Vector3, Vector)): return tuple(vectors) - def column_vector(self, column, *, recursive=True, classes=(Vector3, Vector)): + def column_vector(self, column, *, recursive=True, classes=()): """The selected column of a Matrix as a Vector. If the Matrix is M x N, then this will return a Vector of length M. By default, if M == 3, it will return a Vector3 object instead. Parameters: - column: Index of the column to return. + column (int): Index of the column to return. recursive (bool, optional): True to return corresponding vectors of derivatives. - classes (tuple, optional): A list of classes; an instance of the first - suitable class is returned. + classes (type | list[type] | tuple[type, ...], optional): A list of classes; + an instance of the first suitable class is returned. Default is + [:class:`~polymath.Vector3`, :class:`~polymath.Vector`]. Returns: - Vector or Vector3: The selected column as a vector. + Qube: The selected column as an object in one of the specified `classes`. """ + if not classes: + classes = [Vector3, Vector] + return self.extract_numer(1, column, recursive=recursive, classes=classes) - def column_vectors(self, recursive=True, classes=(Vector3, Vector)): + def column_vectors(self, *, recursive=True, classes=()): """A tuple of Vector objects, one for each column of this Matrix. If the Matrix is M x N, then this will return N Vectors of length M. By default, @@ -130,13 +149,17 @@ def column_vectors(self, recursive=True, classes=(Vector3, Vector)): Parameters: recursive (bool, optional): True to return corresponding vectors of derivatives. - classes (tuple, optional): A list of classes; instances of the first suitable - class are returned. + classes (type | list[type] | tuple[type, ...], optional): A list of classes; + instances of the first suitable class are returned. Default is + [:class:`~polymath.Vector3`, :class:`~polymath.Vector`]. Returns: - tuple: A tuple of Vector objects, one for each column. + tuple[Qube, ...]: A tuple of objects in one of the specified `classes`. """ + if not classes: + classes = [Vector3, Vector] + vectors = [] for col in range(self._numer[1]): vectors.append(self.extract_numer(1, col, recursive=recursive, @@ -148,18 +171,22 @@ def to_vector(self, axis, indx, *, recursive=True, classes=()): """One of the components of a Matrix as a Vector. Parameters: - axis: Axis index from which to extract vector. - indx: Index of the vector along this axis. - classes (list, optional): A list of the Vector subclasses to return. The first - valid one will be used. + axis (int): Axis index from which to extract a vector. + indx (int): Index of the vector along this axis. recursive (bool, optional): True to extract the derivatives as well. + classes (type | list[type] | tuple[type, ...], optional): A list of the Vector + subclasses to return. The first valid one will be used. Default is + [:class:`~polymath.Vector`]. Returns: - Vector: One component of the Matrix as a Vector. + Qube: One component of the Matrix as an object in one of the specified + `classes`. """ - return self.extract_numer(axis, indx, list(classes) + [Vector], - recursive=recursive) + if not classes: + classes = [Vector] + + return self.extract_numer(axis, indx, classes=classes, recursive=recursive) def to_scalar(self, /, indx0, indx1, *, recursive=True): """One of the elements of a Matrix as a Scalar. @@ -173,29 +200,27 @@ def to_scalar(self, /, indx0, indx1, *, recursive=True): Scalar: One element of the Matrix as a Scalar. """ - vector = self.extract_numer(0, indx0, Vector, recursive=recursive) - return vector.extract_numer(0, indx1, Scalar, recursive=recursive) + vector = self.extract_numer(0, indx0, classes=Vector, recursive=recursive) + return vector.extract_numer(0, indx1, classes=Scalar, recursive=recursive) @staticmethod def from_scalars(*args, recursive=True, shape=None, classes=()): """Construct a Matrix or subclass by combining scalars. Parameters: - *args: Any number of Scalars or arguments that can be casted to Scalars. They - need not have the same shape, but it must be possible to broadcast them to - the same shape. A value of None is converted to a zero-valued Scalar that + *args (Any): Any number of objects that can be cast to Scalars. They need + not have the same shape, but it must be possible to broadcast them to the + same shape. A value of None is converted to a zero-valued Scalar that matches the denominator shape of the other arguments. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives - found amongst the scalars. - shape (tuple, optional): The Matrix's item shape. If not specified but the - number of Scalars is a perfect square, a square matrix is returned. - If specified, the number of scalar arguments must equal - shape[0] * shape[1]. Each scalar argument can be a single value or an - array that will be broadcast to match the other arguments. - classes (list, optional): An arbitrary list defining the preferred class of - the returned object. The first suitable class in the list will be used. - Default is [Matrix]. + found amongst the `args`. + shape (tuple[int, ...] | None, optional): The Matrix's item shape. If not + specified but the number of `args` is a perfect square, a square matrix is + returned. + classes (type | list[type] | tuple[type, ...], optional): A list defining the + preferred class of the returned object. The first suitable class in the + list will be used. Default is [:class:`~polymath.Matrix`]. Returns: Matrix: A Matrix constructed from the given scalars. @@ -231,7 +256,10 @@ def from_scalars(*args, recursive=True, shape=None, classes=()): 'with square shape') shape = (dim, dim) - return vector.reshape_numer(shape, list(classes) + [Matrix], recursive=recursive) + if not classes: + classes = [Matrix] + + return vector.reshape_numer(shape, classes=classes, recursive=recursive) def is_diagonal(self, *, delta=0.): """A Boolean equal to True where the matrix is diagonal. @@ -309,18 +337,14 @@ def transpose(self, *, recursive=True): to return an object without derivatives. Returns: - Matrix: Transpose of this matrix. + Matrix: The transpose of this matrix. """ return self.transpose_numer(0, 1, recursive=recursive) @property - def T(self): # noqa: N802 # mirrors the NumPy .T attribute - """The transpose of this matrix. - - Returns: - Matrix: Transpose of this matrix with derivatives included. - """ + def T(self) -> 'Matrix': # noqa: N802 # mirrors the NumPy .T attribute + """The transpose of this matrix, with derivatives included.""" return self.transpose_numer(0, 1, recursive=True) @@ -338,7 +362,7 @@ def inverse(self, *, recursive=True, nozeros=False): Returns: Matrix: Inverse of this matrix. It will have the same subclass as this object. - Matrices with a determinant equal to zero will be masked. + Matrices with a determinant equal to zero are masked. Raises: ValueError: If the matrix is not square or has denominators. @@ -395,10 +419,7 @@ def inverse(self, *, recursive=True, nozeros=False): def unitary(self): """The nearest unitary matrix as a Matrix3. - This method only works for 3x3 matrices. For other matrix sizes, a ValueError - is raised. - - Uses the algorithm from + This method only works for 3x3 matrices. It uses the algorithm from https://wikipedia.org/wiki/Orthogonal_matrix#Nearest_orthogonal_matrix Returns: @@ -445,11 +466,11 @@ def unitary(self): return Qube._MATRIX3_CLASS(next_m._values, new_mask) def solve(self, arg, *, recursive=True, nozeros=False): - """The Vector X that satisfies A X = B, for this square matrix A. + """The Vector ``X`` that satisfies ``A X = B``, for this square matrix ``A``. Parameters: - arg (Vector, array-like): The Vector B of right-hand sides. Its item shape - must match the size of this matrix. + arg (VectorLike): The Vector ``B`` in ``A X = B``. Its item shape must match + the size of this matrix. recursive (bool, optional): True to include the derivatives of the solution, which are derived from those of this matrix and of `arg`. nozeros (bool, optional): False to mask out any matrices with a zero-valued @@ -457,9 +478,9 @@ def solve(self, arg, *, recursive=True, nozeros=False): determinant is nonzero. Returns: - Vector: The solution X, with the leading shape obtained by broadcasting this - matrix against `arg`. Elements where this matrix is singular are masked. The - returned object takes the subclass of `arg` where that subclass fits. + Vector: The solution ``X``, with the leading shape obtained by broadcasting + this matrix against `arg`. Elements where this matrix is singular are masked. + The returned object takes the subclass of `arg` where that subclass fits. Raises: ValueError: If this matrix is not square. @@ -473,6 +494,34 @@ def solve(self, arg, *, recursive=True, nozeros=False): Vector(1.0 1.0) """ + def solve_values(values, denom): + """Solve for one right-hand side, with any denominator axes flattened into + additional columns. + + Parameters: + values (numpy.ndarray): The right-hand side values to solve for. + denom (tuple[int, ...]): The denominator shape, whose axes are flattened + into additional columns. + + Returns: + numpy.ndarray: The solution, reshaped to match `values`. + + Raises: + ValueError: If the matrix is singular. + """ + + columns = values.reshape(new_shape + (size, math.prod(denom))) + + with warnings.catch_warnings(): + warnings.filterwarnings('error') + try: + solution = np.linalg.solve(a_vals, columns) + except (RuntimeWarning, np.linalg.LinAlgError) as err: + raise ValueError(f'{type(self).__name__}.solve() matrix is singular' + ) from err + + return solution.reshape(values.shape) + size = self._numer[0] if self._numer[1] != size: raise ValueError(f'{type(self).__name__}.solve() requires a square matrix; ' @@ -508,28 +557,11 @@ def solve(self, arg, *, recursive=True, nozeros=False): a_vals[singular] = np.diag(np.ones(size)) new_mask = Qube.or_(new_mask, singular) - def solve_values(values, denom): - """Solve for one right-hand side, with any denominator axes flattened into - additional columns. - """ - - columns = values.reshape(new_shape + (size, math.prod(denom))) - - with warnings.catch_warnings(): - warnings.filterwarnings('error') - try: - solution = np.linalg.solve(a_vals, columns) - except (RuntimeWarning, np.linalg.LinAlgError) as err: - raise ValueError(f'{type(self).__name__}.solve() matrix is singular' - ) from err - - return solution.reshape(values.shape) - obj = Vector(solve_values(b._values, ()), new_mask, unit=Unit.div_units(b._unit, a._unit)) - # Differentiating A X = B gives A dX/dt = dB/dt - (dA/dt) X, so each derivative - # is the solution of the same system with a new right-hand side + # Differentiating A X = B gives A dX/dt = dB/dt - (dA/dt) X, so each derivative is + # the solution of the same system with a new right-hand side if recursive and (a._derivs or b._derivs): x = obj.wod new_derivs = {} @@ -547,7 +579,7 @@ def solve_values(values, denom): obj.insert_derivs(new_derivs) - return obj.cast(type(b)) + return obj.cast(classes=type(b)) ###################################################################################### # Overrides of superclass operators @@ -557,6 +589,9 @@ def __abs__(self): """Raise a TypeError; absolute value is not defined for matrices. This is an override of :meth:`Qube.__abs__`. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('abs()', self) @@ -565,6 +600,12 @@ def __floordiv__(self, /, arg): """Raise a TypeError; floor division is not defined for matrices. This is an override of :meth:`Qube.__floordiv__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('//', self, arg) @@ -573,6 +614,12 @@ def __rfloordiv__(self, /, arg): """Raise a TypeError; floor division is not defined for matrices. This is an override of :meth:`Qube.__rfloordiv__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('//', arg, self) @@ -581,6 +628,12 @@ def __ifloordiv__(self, /, arg): """Raise a TypeError; floor division is not defined for matrices. This is an override of :meth:`Qube.__ifloordiv__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('//=', self, arg) @@ -589,6 +642,12 @@ def __mod__(self, /, arg): """Raise a TypeError; modulo is not defined for matrices. This is an override of :meth:`Qube.__mod__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('%', self, arg) @@ -597,6 +656,12 @@ def __rmod__(self, /, arg): """Raise a TypeError; modulo is not defined for matrices. This is an override of :meth:`Qube.__rmod__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('%', arg, self) @@ -605,6 +670,12 @@ def __imod__(self, /, arg): """Raise a TypeError; modulo is not defined for matrices. This is an override of :meth:`Qube.__imod__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('%=', self, arg) @@ -614,6 +685,9 @@ def identity(self): This method overrides :meth:`Qube.identity`. + Returns: + Matrix: An identity matrix. + Raises: ValueError: If the matrix is not square. """ @@ -652,8 +726,8 @@ def reciprocal(self, *, recursive=True, nozeros=False): Matrix: The matrix inverse. Raises: - ValueError: If the matrix is not square, has denominators, or has a - determinant of zero. + ValueError: If the matrix is not square or has denominators. + ValueError: If `nozeros` is True but a determinant of zero is encountered. """ return self.inverse(recursive=recursive, nozeros=nozeros) diff --git a/src/polymath/matrix.pyi b/src/polymath/matrix.pyi deleted file mode 100644 index 98a5f37..0000000 --- a/src/polymath/matrix.pyi +++ /dev/null @@ -1,71 +0,0 @@ -########################################################################################## -# polymath/matrix.pyi -########################################################################################## -"""Type stub for :mod:`polymath.matrix`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any - -from polymath.qube import Qube, _Arraylike, _ShapeOrTuple - -__all__ = ['Matrix'] - -class Matrix(Qube): - IDENTITY2: Matrix - IDENTITY3: Matrix - MASKED2: Matrix - MASKED3: Matrix - @property - def T(self) -> _Arraylike: ... # noqa: N802 - UNIT33: Matrix - XAXIS_COL: Matrix - XAXIS_ROW: Matrix - YAXIS_COL: Matrix - YAXIS_ROW: Matrix - ZAXIS_COL: Matrix - ZAXIS_ROW: Matrix - ZERO33: Matrix - ZERO3_COL: Matrix - ZERO3_ROW: Matrix - def __abs__(self) -> Any: ... # type: ignore[override] - def __floordiv__(self, arg: Any) -> Any: ... - def __ifloordiv__(self, arg: Any) -> Any: ... - def __imod__(self, arg: Any) -> Any: ... # type: ignore[override] - def __mod__(self, arg: Any) -> Any: ... # type: ignore[override] - def __rfloordiv__(self, arg: Any) -> Any: ... - def __rmod__(self, arg: Any) -> Any: ... # type: ignore[override] - @staticmethod - def as_matrix(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def column_vector(self, column: Any, *, recursive: bool = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _Arraylike: ... - def column_vectors(self, recursive: bool = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _ShapeOrTuple: ... - @staticmethod - def from_scalars(*args: Any, recursive: bool = ..., # type: ignore[override] - shape: _ShapeOrTuple | None = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _Arraylike: ... - def identity(self) -> Any: ... - def inverse(self, *, recursive: bool = ..., nozeros: bool = ...) -> _Arraylike: ... - def is_diagonal(self, *, delta: float = ...) -> _Arraylike: ... - def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ...) -> _Arraylike: ... - def row_vector(self, row: Any, *, recursive: bool = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _Arraylike: ... - def row_vectors(self, *, recursive: bool = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _ShapeOrTuple: ... - def solve(self, arg: _Arraylike, *, recursive: bool = ..., - nozeros: bool = ...) -> _Arraylike: ... - def to_scalar(self, indx0: builtins.int, indx1: builtins.int, *, - recursive: bool = ...) -> _Arraylike: ... - def to_vector(self, axis: Any, indx: Any, *, recursive: bool = ..., - classes: type | tuple[type, ...] | list[type] = ...) -> _Arraylike: ... - def transpose(self, *, recursive: bool = ...) -> _Arraylike: ... - def unitary(self) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/matrix3.py b/src/polymath/matrix3.py index 09fb9c1..b90992d 100755 --- a/src/polymath/matrix3.py +++ b/src/polymath/matrix3.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/matrix3.py: Matrix3 subclass of PolyMath Matrix class ########################################################################################## +"""The :class:`~polymath.Matrix3` subclass, representing 3x3 rotation matrices. + +A Matrix3 is a :class:`~polymath.Matrix` whose numerator shape is fixed at ``(3, 3)``. +Because a rotation matrix is orthonormal, its inverse is its transpose, which makes +rotating and unrotating cheap. This class constructs rotations from an axis and an angle, +from Euler angles, or from two vectors, and applies them to any PolyMath object. +""" import numpy as np @@ -51,7 +58,7 @@ def as_matrix3(arg, *, recursive=True): Quaternions are converted to matrices. Parameters: - arg: The object to convert to Matrix3. + arg (Matrix3Like): The object to convert to Matrix3. recursive (bool, optional): True to include derivatives in the returned result. @@ -75,15 +82,15 @@ def as_matrix3(arg, *, recursive=True): def twovec(vector1, axis1, vector2, axis2, *, recursive=True): """A rotation matrix defined by two vectors. - The returned matrix rotates to a right-handed coordinate frame having vector1 - pointing along a specified axis (axis1=0 for X, 1 for Y, 2 for Z) and vector2 - pointing into the half-plane defined by (axis1, axis2). + The returned matrix rotates to a right-handed coordinate frame having `vector1` + pointing along a specified axis (`axis1` = 0 for X, 1 for Y, 2 for Z) and + `vector2` pointing into the half-plane defined by (`axis1`, `axis2`). Parameters: - vector1 (Vector or array-like): The first vector that defines the rotation. - axis1 (int): The axis to which vector1 should point (0=X, 1=Y, 2=Z). - vector2 (Vector or array-like): The second vector that defines the rotation. - axis2 (int): The axis defining the half-plane for vector2 (0=X, 1=Y, 2=Z). + vector1 (Vector3Like): The first vector that defines the rotation. + axis1 (int): The axis to which `vector1` should point (0=X, 1=Y, 2=Z). + vector2 (Vector3Like): The second vector that defines the rotation. + axis2 (int): The axis defining the half-plane for `vector2` (0=X, 1=Y, 2=Z). recursive (bool, optional): True to include derivatives in the result. Returns: @@ -173,21 +180,21 @@ def twovec(vector1, axis1, vector2, axis2, *, recursive=True): @staticmethod def x_rotation(angle, *, recursive=True): - """A rotation matrix about X-axis. + """A rotation matrix about the **X**-axis. The returned matrix rotates a vector counterclockwise about the X-axis by the specified angle in radians. The same matrix rotates a coordinate system clockwise by the same angle. Parameters: - angle (Scalar, array-like, or float): The rotation angle in radians. + angle (ScalarLike): The rotation angle in radians. recursive (bool, optional): True to include derivatives in the result. Returns: Matrix3: A rotation matrix about the X-axis. Raises: - ValueError: If the angle has an invalid unit + ValueError: If the angle has an invalid unit. """ angle = Scalar.as_scalar(angle) @@ -198,8 +205,8 @@ def x_rotation(angle, *, recursive=True): values = np.zeros(angle._shape + (3, 3)) values[..., 1, 1] = cos_angle - values[..., 1, 2] = sin_angle - values[..., 2, 1] = -sin_angle + values[..., 1, 2] = -sin_angle + values[..., 2, 1] = sin_angle values[..., 2, 2] = cos_angle values[..., 0, 0] = 1. @@ -208,8 +215,8 @@ def x_rotation(angle, *, recursive=True): if recursive and angle._derivs: matrix = np.zeros(angle._shape + (3, 3)) matrix[..., 1, 1] = -sin_angle - matrix[..., 1, 2] = cos_angle - matrix[..., 2, 1] = -cos_angle + matrix[..., 1, 2] = -cos_angle + matrix[..., 2, 1] = cos_angle matrix[..., 2, 2] = -sin_angle for key, deriv in angle._derivs.items(): @@ -219,21 +226,21 @@ def x_rotation(angle, *, recursive=True): @staticmethod def y_rotation(angle, *, recursive=True): - """A rotation matrix about Y-axis. + """A rotation matrix about the **Y**-axis. The returned matrix rotates a vector counterclockwise about the Y-axis by the specified angle in radians. The same matrix rotates a coordinate system clockwise by the same angle. Parameters: - angle (Scalar, array-like, or float): The rotation angle in radians. + angle (ScalarLike): The rotation angle in radians. recursive (bool, optional): True to include derivatives in the result. Returns: Matrix3: A rotation matrix about the Y-axis. Raises: - ValueError: If the angle has an invalid unit + ValueError: If the angle has an invalid unit. """ angle = Scalar.as_scalar(angle) @@ -265,21 +272,21 @@ def y_rotation(angle, *, recursive=True): @staticmethod def z_rotation(angle, *, recursive=True): - """A rotation matrix about Z-axis. + """A rotation matrix about the **Z**-axis. The returned matrix rotates a vector counterclockwise about the Z-axis by the specified angle in radians. The same matrix rotates a coordinate system clockwise by the same angle. Parameters: - angle (Scalar, array-like, or float): The rotation angle in radians. + angle (ScalarLike): The rotation angle in radians. recursive (bool, optional): True to include derivatives in the result. Returns: Matrix3: A rotation matrix about the Z-axis. Raises: - ValueError: If the angle has an invalid unit + ValueError: If the angle has an invalid unit. """ angle = Scalar.as_scalar(angle) @@ -314,16 +321,19 @@ def axis_rotation(angle, axis=2, *, recursive=True): """A rotation matrix about one of the three primary axes. The returned matrix rotates a vector counterclockwise by the specified angle about - the specified axis (0 for X, 1 for Y, 2 for Z). The same matrix rotates a - coordinate system clockwise by the same angle. + the specified axis (0 for **X**, 1 for **Y**, 2 for **Z**). The same matrix + rotates a coordinate system clockwise by the same angle. Parameters: - angle (Scalar, array-like, or float): The rotation angle in radians. + angle (ScalarLike): The rotation angle in radians. axis (int, optional): The axis to rotate around (0=X, 1=Y, 2=Z). recursive (bool, optional): True to include derivatives in the result. Returns: Matrix3: A rotation matrix about the specified axis. + + Raises: + ValueError: If the angle has an invalid unit. """ axis = axis % 3 @@ -340,19 +350,19 @@ def axis_rotation(angle, axis=2, *, recursive=True): def pole_rotation(ra, dec): """Create a rotation matrix to a frame defined by right ascension and declination. - The returned matrix rotates coordinates into a frame where the Z-axis is defined - by (ra, dec) and the X-axis points along the new equatorial plane's ascending node - on the original equator. + The returned matrix rotates coordinates into a frame where the **Z**-axis is + defined by `(ra, dec)` and the **X**-axis points along the new equatorial plane's + ascending node on the original equator. Parameters: - ra: The right ascension of the Z-axis in radians. - dec: The declination of the Z-axis in radians. + ra (ScalarLike): The right ascension of the **Z**-axis in radians. + dec (ScalarLike): The declination of the **Z**-axis in radians. Returns: - Matrix3: A rotation matrix to the frame defined by (ra,dec). + Matrix3: A rotation matrix to the frame defined by (`ra`, `dec`). Raises: - ValueError: If ra or dec has an invalid unit. + ValueError: If `ra` or `dec` has an invalid unit. Notes: Derivatives are not supported. @@ -382,9 +392,9 @@ def rotate(self, arg, *, recursive=True): """Rotate an object by this Matrix3, returning an instance of the same subclass. Parameters: - arg: The object to rotate. Can be a Vector3, Matrix3, or other Qube object. - When rotating Matrix3 objects, ensure compatible shapes for proper - broadcasting. Scalars are returned unchanged. + arg (Qube): The object to rotate, which can be a Vector3, Matrix3, or any + other Qube object. When rotating Matrix3 objects, ensure compatible shapes + for proper broadcasting. Scalars are returned unchanged. recursive (bool, optional): If True, the rotated derivatives are included in the object returned. @@ -394,9 +404,9 @@ def rotate(self, arg, *, recursive=True): returned unchanged. Notes: - The shapes of this Matrix3 and the argument are broadcast together following - NumPy broadcasting rules. For Matrix3 objects, the matrix multiplication - requires compatible shapes between the leading dimensions. + The shapes of this Matrix3 and the argument are broadcast together. For + Matrix3 objects, the matrix multiplication requires compatible shapes between + the leading dimensions. """ # Rotation of a vector or matrix @@ -411,12 +421,21 @@ def unrotate(self, arg, *, recursive=True): """Rotate an object by the inverse of this Matrix3, returning the same subclass. Parameters: - arg: The object to unrotate. - recursive (bool, optional): If True, the un-rotated derivatives are included + arg (Qube): The object to unrotate, which can be a Vector3, Matrix3, or any + other Qube object. When unrotating Matrix3 objects, ensure compatible + shapes for proper broadcasting. Scalars are returned unchanged. + recursive (bool, optional): If True, the unrotated derivatives are included in the object returned. Returns: - Qube: The unrotated object of the same type as the input. + Qube: The unrotated object of the same type as the input. For vectors and + matrices, this performs matrix multiplication. For scalars, the object is + returned unchanged. + + Notes: + The shapes of this Matrix3 and the argument are broadcast together. For + Matrix3 objects, the matrix multiplication requires compatible shapes between + the leading dimensions. """ # Rotation of a vector or matrix @@ -432,76 +451,117 @@ def unrotate(self, arg, *, recursive=True): ###################################################################################### def __neg__(self): - """Raise a TypeError; "-self" is not permitted for Matrix3 objects. + """Raise a TypeError; ``-self`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__neg__`. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('-', self) def __add__(self, /, arg): - """Raise a TypeError; "self + arg" is not permitted for Matrix3 objects. + """Raise a TypeError; ``self + arg`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__add__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('+', self, arg) def __radd__(self, /, arg): - """Raise a TypeError; "arg + self" is not permitted for Matrix3 objects. + """Raise a TypeError; ``arg + self`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__radd__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('+', self, arg) def __iadd__(self, /, arg): - """Raise a TypeError; "self += arg" is not permitted for Matrix3 objects. + """Raise a TypeError; ``self += arg`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__iadd__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('+=', self, arg) def __sub__(self, /, arg): - """Raise a TypeError; "self - arg" is not permitted for Matrix3 objects. + """Raise a TypeError; ``self - arg`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__sub__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('-', self, arg) def __rsub__(self, /, arg): - """Raise a TypeError; "arg - self" is not permitted for Matrix3 objects. + """Raise a TypeError; ``arg - self`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__rsub__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('-', self, arg) def __isub__(self, /, arg): - """Raise a TypeError; "self -= arg" is not permitted for Matrix3 objects. + """Raise a TypeError; ``self -= arg`` is not permitted for Matrix3 objects. This is an override of :meth:`Qube.__isub__`. + + Parameters: + arg (Any): The other operand, which is only used to build the error message. + + Raises: + TypeError: Always, because the operation is not defined for this class. """ Qube._raise_unsupported_op('-=', self, arg) def __mul__(self, /, arg, *, recursive=True): - """self * arg, matrix multiplication. + """``self * arg``, matrix multiplication. Matrix3 times Scalar returns the same type of Scalar. This overrides :meth:`Qube.__mul__`. Parameters: - arg: The object to multiply with this Matrix3. + arg (Any): The object to multiply by this Matrix3. recursive (bool, optional): True to include derivatives in the result. Returns: Qube: The result of the multiplication. Raises: - ValueError: If multiplication with the given type is not supported. + TypeError: If the type of `arg` is not supported for multiplication. + ValueError: If `arg` is array-like and its item shape is incompatible, or if + the object shapes are incompatible. """ # Convert arg to a Scalar if necessary @@ -520,20 +580,22 @@ def __mul__(self, /, arg, *, recursive=True): return Qube.__mul__(self, original_arg) def __rmul__(self, /, arg, *, recursive=True): - """arg * self, matrix multiplication. + """``arg * self``, matrix multiplication. Matrix3 times Scalar returns the same type of Scalar. This overrides :meth:`Qube.__rmul__`. Parameters: - arg: The object to multiply with this Matrix3. + arg (Any): The object to convert to Matrix3 and multiply with this Matrix3. recursive (bool, optional): True to include derivatives in the result. Returns: Qube: The result of the multiplication. Raises: - ValueError: If multiplication with the given type is not supported. + TypeError: If the type of `arg` is not supported for multiplication. + ValueError: If `arg` is array-like and its item shape is incompatible, or if + the object shapes are incompatible. """ # Attempt a conversion to Matrix3 @@ -541,25 +603,26 @@ def __rmul__(self, /, arg, *, recursive=True): try: arg = Matrix3.as_matrix3(arg) except (ValueError, TypeError): - Qube._raise_unsupported_op('=', self, original_arg) + Qube._raise_unsupported_op('*', self, original_arg) # For every other purpose, use the default multiply return Qube.__mul__(arg, self) def __imul__(self, /, arg): - """self * arg, in-place matrix multiplication. + """``self * arg``, in-place matrix multiplication. This overrides :meth:`Qube.__imul__`. Parameters: - arg: The Matrix3 by which to multiply this Matrix3. + arg (Any): The object by which to multiply this Matrix3. Returns: - Matrix3: This object, the result of the multiplication. + Matrix3: This object overwritten, the result of the multiplication. Raises: - ValueError: If arg cannot be converted to a Matrix3 or if this Matrix3 is not - writeable. + TypeError: If the type of `arg` is not supported for multiplication. + ValueError: If `arg` is array-like and its item shape is incompatible, if the + object shapes are incompatible, or if this Matrix3 is not writable. """ self.require_writeable() @@ -633,13 +696,14 @@ def reciprocal(self, *, recursive=True, nozeros=False): @staticmethod def from_euler(ai, aj, ak, axes='rzxz'): - """Create a homogeneous rotation matrix from Euler angles and axis sequence. + """Create a rotation matrix from Euler angles and an axis sequence. Parameters: - ai: First Euler angle (roll). - aj: Second Euler angle (pitch). - ak: Third Euler angle (yaw). - axes (str, optional): One of 24 axis sequences as string or encoded tuple. + ai (ScalarLike): First Euler angle (roll) in radians. + aj (ScalarLike): Second Euler angle (pitch) in radians. + ak (ScalarLike): Third Euler angle (yaw) in radians. + axes (str | tuple[int, int, int, int], optional): One of 24 axis sequences as + a string or an encoded tuple. Returns: Matrix3: A rotation matrix representing the specified Euler angles. @@ -725,10 +789,12 @@ def to_euler(self, axes='rzxz'): """Convert this Matrix3 to three Euler angles given a specified axis sequence. Parameters: - axes (str, optional): One of 24 axis sequences as string or encoded tuple. + axes (str | tuple[int, int, int, int], optional): One of 24 axis sequences as + a string or an encoded tuple. Returns: - tuple: Three Scalars representing the Euler angles (roll, pitch, yaw). + tuple[Scalar, Scalar, Scalar]: The three Euler angles (roll, pitch, yaw) in + radians, each in the range 0 to 2 pi. Raises: KeyError: If the axes string is not recognized. @@ -791,7 +857,7 @@ def to_euler(self, axes='rzxz'): Scalar._new_from_parts(ay[0] % Matrix3._TWOPI, self._mask, nrank=0), Scalar._new_from_parts(az[0] % Matrix3._TWOPI, self._mask, nrank=0)) - def to_quaternion(self, recursive=True): + def to_quaternion(self, *, recursive=True): """Convert this Matrix3 to an equivalent unit Quaternion. Parameters: @@ -810,15 +876,16 @@ def sum(self, axis=None, *, recursive=True, builtins=None, out=None): :meth:`Qube.sum`. Parameters: - axis (int or tuple, optional): The axis or axes over which the sum is to be - performed, leaving any remaining axes in the returned value. If not - specified, the sum is performed across all axes. - recursive (bool, optional): True to include the sums of the derivatives - inside the returned Scalar. - builtins: If True and the result is a single unmasked scalar, the result is - returned as a Python int or float instead of as an instance of Qube. - Default is specified by Qube.prefer_builtins(). - out: Ignored. Enables "np.sum(Qube)" to work. + axis (int | tuple[int, ...] | None, optional): The axis or axes over which the + sum is to be performed, leaving any remaining axes in the returned value. + If not specified, the sum is performed across all axes. + recursive (bool, optional): True to include the sums of the derivatives inside + the returned Scalar. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of as an + instance of Qube. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + out (Any, optional): Ignored. This enables ``np.sum(Qube)`` to work. Raises: TypeError: Always raised as this method is not supported for Matrix3. @@ -833,16 +900,17 @@ def mean(self, axis=None, *, recursive=True, builtins=None, dtype=None, out=None :meth:`Qube.mean`. Parameters: - axis (int or tuple, optional): The axis or axes over which the mean is to be - performed, leaving any remaining axes in the returned value. If not - specified, the mean is performed across all axes. + axis (int | tuple[int, ...] | None, optional): The axis or axes over which the + mean is to be performed, leaving any remaining axes in the returned value. + If not specified, the mean is performed across all axes. recursive (bool, optional): True to include the means of the derivatives inside the returned Scalar. - builtins: If True and the result is a single unmasked scalar, the - result is returned as a Python int or float instead of as an - instance of Scalar. Default is specified by Qube.prefer_builtins(). - dtype: Ignored. Enables "np.mean(Qube)" to work. - out: Ignored. Enables "np.mean(Qube)" to work. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of as an + instance of Qube. Default is to use the global setting defined by + :meth:`~polymath.Qube.prefer_builtins`. + dtype (Any, optional): Ignored. This enables ``np.mean(Qube)`` to work. + out (Any, optional): Ignored. This enables ``np.mean(Qube)`` to work. Raises: TypeError: Always raised as this method is not supported for Matrix3. @@ -972,7 +1040,8 @@ def __setstate__(self, state): of Qube.__getstate__() are recognized. Parameters: - state (dict): The state dictionary as returned by __getstate__(). + state (dict[str, Any]): The state dictionary as returned by + :meth:`~__getstate__`. """ if 'QUATERNION_ENCODING' not in state: diff --git a/src/polymath/matrix3.pyi b/src/polymath/matrix3.pyi deleted file mode 100644 index 64cfe03..0000000 --- a/src/polymath/matrix3.pyi +++ /dev/null @@ -1,64 +0,0 @@ -########################################################################################## -# polymath/matrix3.pyi -########################################################################################## -"""Type stub for :mod:`polymath.matrix3`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any - -from polymath.matrix import Matrix -from polymath.qube import Qube, _Arraylike, _ShapeOrTuple - -__all__ = ['Matrix3'] - -class Matrix3(Matrix): - IDENTITY: Matrix3 - MASKED: Matrix3 - def __add__(self, arg: Any) -> Any: ... # type: ignore[override] - def __getstate__(self) -> dict[str, Any]: ... - def __iadd__(self, arg: Any) -> Any: ... # type: ignore[override] - def __imul__(self, arg: Any) -> _Arraylike: ... # type: ignore[misc, override] - def __isub__(self, arg: Any) -> Any: ... # type: ignore[override] - def __mul__(self, arg: Any, *, recursive: bool = ...) -> Qube: ... # type: ignore[override] - def __neg__(self) -> Any: ... # type: ignore[override] - def __radd__(self, arg: Any) -> Any: ... # type: ignore[override] - def __rmul__(self, arg: Any, *, recursive: bool = ...) -> Qube: ... # type: ignore[override] - def __rsub__(self, arg: Any) -> Any: ... # type: ignore[override] - def __setstate__(self, state: dict[str, Any]) -> None: ... - def __sub__(self, arg: Any) -> Any: ... # type: ignore[override] - @staticmethod - def as_matrix3(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def axis_rotation(angle: Any, axis: builtins.int = ..., *, - recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_euler(ai: Any, aj: Any, ak: Any, axes: str = ...) -> _Arraylike: ... - def mean(self, axis: Any = ..., *, recursive: bool = ..., builtins: Any = ..., # type: ignore[override] - dtype: Any = ..., out: Any = ...) -> Any: ... - @staticmethod - def pole_rotation(ra: Any, dec: Any) -> _Arraylike: ... - def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ...) -> _Arraylike: ... - def rotate(self, arg: Any, *, recursive: bool = ...) -> Qube: ... - def sum(self, axis: Any = ..., *, recursive: bool = ..., builtins: Any = ..., # type: ignore[override] - out: Any = ...) -> Any: ... - def to_euler(self, axes: str = ...) -> _ShapeOrTuple: ... - def to_quaternion(self, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def twovec(vector1: _Arraylike, axis1: builtins.int, vector2: _Arraylike, - axis2: builtins.int, *, recursive: bool = ...) -> _Arraylike: ... - def unrotate(self, arg: Any, *, recursive: bool = ...) -> Qube: ... - @staticmethod - def x_rotation(angle: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def y_rotation(angle: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def z_rotation(angle: Any, *, recursive: bool = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/pair.py b/src/polymath/pair.py index 9aed424..81ba0a0 100755 --- a/src/polymath/pair.py +++ b/src/polymath/pair.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/pair.py: Pair subclass of PolyMath Vector ########################################################################################## +"""The :class:`~polymath.Pair` subclass, representing coordinate pairs and 2-vectors. + +A Pair is a :class:`~polymath.Vector` whose numerator shape is fixed at ``(2,)``. It adds +the operations that are natural in two dimensions: swapping the components, rotating by 90 +degrees, measuring a polar angle, and converting to and from a pair of Scalars. +""" import numpy as np import numbers @@ -33,7 +39,7 @@ def as_pair(arg, *, recursive=True): """Convert the argument to Pair if possible. Parameters: - arg (object): The object to convert to Pair. + arg (PairLike): The object to convert to Pair. recursive (bool, optional): If True, derivatives will also be converted. Returns: @@ -53,11 +59,11 @@ def as_pair(arg, *, recursive=True): # Collapse a 1x2 or 2x1 Matrix down to a Pair if arg._numer in ((1, 2), (2, 1)): - return arg.flatten_numer(Pair, recursive=recursive) + return arg.flatten_numer(classes=Pair, recursive=recursive) # For any suitable Qube, move numerator items to the denominator if arg.rank > 1 and arg._numer[0] == 2: - arg = arg.split_items(1, Pair) + arg = arg.split_items(1, classes=Pair) arg = Pair(arg._values, arg._mask, example=arg) return arg if recursive else arg.wod @@ -74,8 +80,8 @@ def from_scalars(x, y, *, recursive=True, readonly=False): """Construct a Pair by combining two scalars. Parameters: - x (Scalar or convertible): First component of the pair. - y (Scalar or convertible): Second component of the pair. + x (ScalarLike | None): First component of the pair. + y (ScalarLike | None): Second component of the pair. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives found amongst the scalars. @@ -83,12 +89,12 @@ def from_scalars(x, y, *, recursive=True, readonly=False): something potentially writable. Returns: - Pair: A new Pair object constructed from the two scalars. + Pair: A new Pair constructed from the two scalars. Notes: Input arguments need not have the same shape, but it must be possible to cast them to the same shape. A value of None is converted to a zero-valued Scalar - that matches the denominator shape of the other arguments. + that matches the denominator shape of the other argument. """ # Convert all non-None args to Scalars @@ -123,7 +129,7 @@ def swapxy(self, *, recursive=True): recursive (bool, optional): If True, derivatives will also be swapped. Returns: - Pair: A new Pair with x and y values swapped. + Pair: A new Pair with **x** and **y** values swapped. """ if not recursive: @@ -150,13 +156,13 @@ def swapxy(self, *, recursive=True): return obj def rot90(self, *, recursive=True): - """A pair object rotated 90 degrees from the origin, (x,y) -> (y,-x). + """A pair object rotated 90 degrees about the origin, ``(x,y) -> (y,-x)``. Parameters: recursive (bool, optional): If True, derivatives will also be rotated. Returns: - Pair: A new Pair rotated 90 degrees counterclockwise. + Pair: A new Pair rotated 90 degrees clockwise. """ # Roll the array axis to the end @@ -181,7 +187,7 @@ def rot90(self, *, recursive=True): return obj def angle(self, *, recursive=True): - """The polar angle of this Pair measured from the X-axis toward the Y-axis. + """The polar angle of this Pair, from the **X**-axis toward the **Y**-axis. The returned value will always fall between zero and 2*pi. @@ -189,7 +195,7 @@ def angle(self, *, recursive=True): recursive (bool, optional): True to include the derivatives. Returns: - Scalar: The angle in radians, between 0 and 2π. + Scalar: The angle in radians, between 0 and 2*pi. """ (x, y) = self.to_scalars(recursive=recursive) @@ -202,18 +208,18 @@ def clip2d(self, lower, upper, *, remask=False): and upper limits. Parameters: - lower (Pair or None): Coordinates of the lower limit. None or masked value to - ignore. - upper (Pair or None): Coordinates of the upper limit (inclusive). None or a - masked value to ignore. - remask (bool, optional): True to keep the mask; False to replace the - values but make them unmasked. + lower (PairLike | None): Coordinates of the lower limit. None or a masked + value to ignore the lower limit. + upper (PairLike | None): Coordinates of the upper limit (inclusive). None or a + masked value to ignore the upper limit. + remask (bool, optional): True to keep the mask; False to replace the values + but make them unmasked. Returns: Pair: A new Pair with values clipped to the specified limits. Raises: - ValueError: If lower or upper has more than two values. + ValueError: If `lower` or `upper` does not contain exactly two values. """ # Make sure the lower limit is either None or an unmasked Pair @@ -249,8 +255,8 @@ def clip2d(self, lower, upper, *, remask=False): # Clip... result = self - result = result.clip_component(0, lower0, upper0, remask) - result = result.clip_component(1, lower1, upper1, remask) + result = result.clip_component(0, lower0, upper0, remask=remask) + result = result.clip_component(1, lower1, upper1, remask=remask) return result ########################################################################################## diff --git a/src/polymath/pair.pyi b/src/polymath/pair.pyi deleted file mode 100644 index 0d65c73..0000000 --- a/src/polymath/pair.pyi +++ /dev/null @@ -1,41 +0,0 @@ -########################################################################################## -# polymath/pair.pyi -########################################################################################## -"""Type stub for :mod:`polymath.pair`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -from typing import Any - -from polymath.qube import _Arraylike -from polymath.vector import Vector - -__all__ = ['Pair'] - -class Pair(Vector): - HALF: Pair - IDENTITY: Pair - INT00: Pair - INT11: Pair - MASKED: Pair - ONES: Pair - XAXIS: Pair - YAXIS: Pair - ZERO: Pair - ZEROS: Pair - def angle(self, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def as_pair(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def clip2d(self, lower: Any, upper: Any, *, remask: bool = ...) -> _Arraylike: ... - @staticmethod - def from_scalars(x: Any, y: Any, *, recursive: bool = ..., # type: ignore[override] - readonly: bool = ...) -> _Arraylike: ... - def rot90(self, *, recursive: bool = ...) -> _Arraylike: ... - def swapxy(self, *, recursive: bool = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/polynomial.py b/src/polymath/polynomial.py index 0c6d36c..e0de87d 100644 --- a/src/polymath/polynomial.py +++ b/src/polymath/polynomial.py @@ -1,6 +1,12 @@ ########################################################################################## # polymath/polynomial.py: Polynomial subclass of Vector ########################################################################################## +"""The :class:`~polymath.Polynomial` subclass, representing polynomials of one variable. + +A Polynomial is a :class:`~polymath.Vector` whose components are the coefficients of a +polynomial, ordered from the highest power down to the constant term. This class evaluates +polynomials, differentiates them, and finds their roots. +""" import numpy as np @@ -18,12 +24,13 @@ class Polynomial(Vector): This is a Vector subclass in which the elements are interpreted as the coefficients of a polynomial in a single variable x. Coefficients appear in order of decreasing exponent. For example: - - [a, b, c] represents a*x^2 + b*x + c - - [a, b] represents a*x + b - - [a] represents the constant a - Mathematical operations, polynomial root-solving are supported. Coefficients - can have derivatives and these can be used to determine derivatives of the values or + * ``[a, b, c]`` represents ``a*x**2 + b*x + c`` + * ``[a, b]`` represents ``a*x + b`` + * ``[a]`` represents the constant ``a`` + + Mathematical operations and polynomial root-solving are supported. Coefficients can + have derivatives, and these can be used to determine derivatives of the values or roots. """ @@ -33,9 +40,8 @@ def __init__(self, *args, **kwargs): """Initialize a Polynomial object. Parameters: - *args: Arguments to pass to the Vector constructor. If a single argument is a - subclass of Vector, it is quickly converted to class Polynomial. - **kwargs: Keyword arguments to pass to the Vector constructor. + *args (Any): Arguments to pass to the Vector constructor. + **kwargs (Any): Keyword arguments to pass to the Vector constructor. Notes: If a single argument is a subclass of Vector, it is quickly converted to class @@ -74,13 +80,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @property - def order(self): - """The order of the polynomial, i.e., the largest exponent. - - Returns: - int: The order of the polynomial. - """ - + def order(self) -> int: + """The order of the polynomial, i.e., the largest exponent.""" return self.item[-self._drank - 1] - 1 @staticmethod @@ -88,7 +89,7 @@ def as_polynomial(arg, *, recursive=True): """A shallow copy of the given object as class Polynomial. Parameters: - arg: Object to convert to Polynomial. + arg (VectorLike): Object to convert to Polynomial. recursive (bool, optional): True to include derivatives in the conversion. Returns: @@ -171,8 +172,8 @@ def at_least_order(self, order, *, recursive=True): def set_order(self, order, *, recursive=True): """This Polynomial expressed with exactly this order. - Extra polynomial coefficients are filled with zeros. If this Polynomial exceeds - this order requested, raise an exception. + Extra leading polynomial coefficients are filled with zeros. If the order of this + Polynomial exceeds the order requested, a ValueError is raised. Parameters: order (int): Exact order of the Polynomial. @@ -194,22 +195,23 @@ def set_order(self, order, *, recursive=True): def invert_line(self, *, recursive=True): """The inversion of this linear polynomial. - If this polynomial represents y = a*x + b, then the inverse polynomial - represents x = (y - b) / a = (1/a)*y - b/a. + If this polynomial represents ``y = a*x + b``, then the inverse polynomial + represents ``x = (y - b) / a = (1/a)*y - b/a``. Parameters: recursive (bool, optional): True to include derivatives in the conversion. Returns: Polynomial: The inverted linear polynomial. Any element whose leading - coefficient a is zero is masked. + coefficient ``a`` is zero is masked. Raises: ValueError: If the polynomial is not first-order. Notes: Derivatives are propagated by the chain rule, so the derivatives of the - returned coefficients are d(1/a) = -da/a**2 and d(-b/a) = -db/a + b*da/a**2. + returned coefficients are ``d(1/a) = -da/a**2`` and + ``d(-b/a) = -db/a + b*da/a**2``. """ if self.order != 1: @@ -243,7 +245,7 @@ def __add__(self, arg): """Add this polynomial to another polynomial or scalar. Parameters: - arg: The polynomial or scalar to add to this polynomial. + arg (Any): The polynomial or scalar to add to this polynomial. Returns: Polynomial: The sum of the polynomials. @@ -257,7 +259,7 @@ def __radd__(self, arg): """Add this polynomial to another polynomial or scalar (right addition). Parameters: - arg: The polynomial or scalar to add to this polynomial. + arg (Any): The polynomial or scalar to add to this polynomial. Returns: Polynomial: The sum of the polynomials. @@ -269,7 +271,7 @@ def __iadd__(self, arg): """Add another polynomial to this polynomial in-place. Parameters: - arg: The polynomial to add to this polynomial. + arg (Any): The polynomial or scalar to add to this polynomial. Returns: Polynomial: This polynomial modified in-place. @@ -315,7 +317,7 @@ def __sub__(self, arg): """Subtract another polynomial or scalar from this polynomial. Parameters: - arg: The polynomial or scalar to subtract from this polynomial. + arg (Any): The polynomial or scalar to subtract from this polynomial. Returns: Polynomial: The difference of the polynomials. @@ -329,7 +331,7 @@ def __rsub__(self, arg): """Subtract this polynomial from another polynomial or scalar. Parameters: - arg: The polynomial or scalar from which to subtract this polynomial. + arg (Any): The polynomial or scalar from which to subtract this polynomial. Returns: Polynomial: The difference of the polynomials. @@ -343,7 +345,7 @@ def __isub__(self, arg): """Subtract another polynomial from this polynomial in-place. Parameters: - arg: The polynomial to subtract from this polynomial. + arg (Any): The polynomial or scalar to subtract from this polynomial. Returns: Polynomial: This polynomial modified in-place. @@ -389,16 +391,14 @@ def __mul__(self, arg): """Multiply this polynomial by another polynomial or scalar. Parameters: - arg: The polynomial or scalar to multiply with this polynomial. + arg (Any): The polynomial or scalar to multiply with this polynomial. Returns: Polynomial: The product of the polynomials. Raises: - ValueError: If the polynomials have incompatible denominators. This - occurs when self._drank != arg._drank and both are non-zero. For example, - a polynomial with drank=1 cannot be multiplied by a polynomial with - drank=2. + ValueError: If `arg` is a Polynomial whose number of denominator axes differs + from that of this polynomial. """ # Support for Polynomial multiplication @@ -472,18 +472,19 @@ def __rmul__(self, arg): """Multiply another polynomial or scalar by this polynomial. Parameters: - arg: The polynomial or scalar to multiply with this polynomial. + arg (Any): The polynomial or scalar to multiply with this polynomial. Returns: Polynomial: The product of the polynomials. """ + return self.__mul__(arg) def __imul__(self, arg): """Multiply this polynomial by another polynomial or scalar in-place. Parameters: - arg: The polynomial or scalar to multiply with this polynomial. + arg (Any): The polynomial or scalar to multiply with this polynomial. Returns: Polynomial: This polynomial modified in-place. @@ -500,7 +501,7 @@ def __truediv__(self, arg): """Divide this polynomial by another polynomial or scalar. Parameters: - arg: The polynomial or scalar by which to divide this polynomial. + arg (Any): The polynomial or scalar by which to divide this polynomial. Returns: Polynomial: The quotient of the polynomials. @@ -516,7 +517,7 @@ def __itruediv__(self, arg): """Divide this polynomial by another polynomial or scalar in-place. Parameters: - arg: The polynomial or scalar by which to divide this polynomial. + arg (Any): The polynomial or scalar by which to divide this polynomial. Returns: Polynomial: This polynomial modified in-place. @@ -532,10 +533,8 @@ def __itruediv__(self, arg): def __pow__(self, arg): """Raise this polynomial to the specified power. - Uses repeated squaring algorithm for efficient computation. - Parameters: - arg: The exponent (must be a non-negative integer). + arg (int | float): The exponent, which must have a non-negative integer value. Returns: Polynomial: This polynomial raised to the specified power. @@ -570,10 +569,10 @@ def __eq__(self, arg): """Check if this polynomial equals another polynomial. Parameters: - arg: The polynomial to compare with this polynomial. + arg (Any): The polynomial to compare with this polynomial. Returns: - bool: True if the polynomials are equal, False otherwise. + Boolean: True where the polynomials are equal, False otherwise. """ arg = Polynomial.as_polynomial(arg).at_least_order(self.order) @@ -584,10 +583,10 @@ def __ne__(self, arg): """Check if this polynomial does not equal another polynomial. Parameters: - arg: The polynomial to compare with this polynomial. + arg (Any): The polynomial to compare with this polynomial. Returns: - bool: True if the polynomials are not equal, False otherwise. + Boolean: True where the polynomials are not equal, False otherwise. """ arg = Polynomial.as_polynomial(arg).at_least_order(self.order) @@ -598,12 +597,12 @@ def __ne__(self, arg): # Special Polynomial operations ###################################################################################### - def deriv(self, recursive=True): + def deriv(self, *, recursive=True): """The first derivative of this Polynomial. Parameters: - recursive (bool, optional): True to evaluate derivatives as well. - Defaults to True. + recursive (bool, optional): True to include the derivatives of the + coefficients in the result. Returns: Polynomial: The derivative polynomial. @@ -624,18 +623,15 @@ def deriv(self, recursive=True): return result - def eval(self, x, recursive=True): - """Evaluate the polynomial at x. + def eval(self, x, *, recursive=True): + """Evaluate the polynomial at `x`. Parameters: - x: Scalar at which to evaluate the Polynomial. + x (ScalarLike): Scalar at which to evaluate this Polynomial. recursive (bool, optional): True to evaluate derivatives as well. - Defaults to True. Returns: - Scalar: A Scalar of values. The shapes of self and x are broadcasted - together following NumPy broadcasting rules. If self has shape (m, n) and - x has shape (p,), the result will have the broadcasted shape. + Scalar: The Polynomial values. """ if self.order == 0: @@ -711,19 +707,18 @@ def eval(self, x, recursive=True): return Qube.dot(self, x_powers, 0, 0, classes=[Scalar], recursive=recursive) - def roots(self, recursive=True): + def roots(self, *, recursive=True): """Find the roots of the polynomial. Parameters: - recursive (bool, optional): True to evaluate derivatives at the roots - as well. Defaults to True. + recursive (bool, optional): True to evaluate derivatives at the roots as well. Returns: - Scalar: A Scalar of roots. This has the same shape as self but an extra - leading axis matching the order of the polynomial. The leading index - selects among the roots of the polynomial. Roots appear in increasing - order and without any duplicates. Complex roots are masked. If fewer real - roots exist, the set of roots is padded at the end with masked values. + Scalar: The roots. This has the same shape as this object but with an extra + leading axis of length matching the order of the polynomial. The leading index + selects among the roots of the polynomial. Roots appear in increasing order + and without any duplicates. Complex roots are masked. If fewer real roots + exist, the set of roots is padded at the end with masked values. Raises: ValueError: If the polynomial is of order zero. @@ -778,14 +773,14 @@ def roots(self, recursive=True): coefficients[all_zeros, 0] = 1. poly_mask |= all_zeros -# N = len(p) -# if N > 1: -# # build companion matrix and find its eigenvalues (the roots) -# A = diag(np.ones((N-2,), p.dtype), -1) -# A[0,:] = -p[1:] / p[0] -# roots = np.linalg.eigvals(A) -# else: -# roots = np.array([]) + # N = len(p) + # if N > 1: + # # build companion matrix and find its eigenvalues (the roots) + # A = diag(np.ones((N-2,), p.dtype), -1) + # A[0,:] = -p[1:] / p[0] + # roots = np.linalg.eigvals(A) + # else: + # roots = np.array([]) # Shift coefficients till the leading coefficient is nonzero shifts = (coefficients[..., 0] == 0.) diff --git a/src/polymath/polynomial.pyi b/src/polymath/polynomial.pyi deleted file mode 100644 index 6b1189c..0000000 --- a/src/polymath/polynomial.pyi +++ /dev/null @@ -1,51 +0,0 @@ -########################################################################################## -# polymath/polynomial.pyi -########################################################################################## -"""Type stub for :mod:`polymath.polynomial`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any - -from polymath.qube import _Arraylike -from polymath.vector import Vector - -__all__ = ['Polynomial'] - -class Polynomial(Vector): - def __add__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __eq__(self, arg: object) -> Any: ... - def __iadd__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __imul__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __init__(self, *args: Any, **kwargs: Any) -> None: ... - def __isub__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __itruediv__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __mul__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __ne__(self, arg: object) -> Any: ... - def __neg__(self) -> _Arraylike: ... # type: ignore[override] - def __pow__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __radd__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __rmul__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __rsub__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __sub__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - def __truediv__(self, arg: Any) -> _Arraylike: ... # type: ignore[override] - @staticmethod - def as_polynomial(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def as_vector(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def at_least_order(self, order: builtins.int, *, - recursive: bool = ...) -> _Arraylike: ... - def deriv(self, recursive: bool = ...) -> _Arraylike: ... - def eval(self, x: Any, recursive: bool = ...) -> _Arraylike: ... - def invert_line(self, *, recursive: bool = ...) -> _Arraylike: ... - @property - def order(self) -> builtins.int: ... - def roots(self, recursive: bool = ...) -> _Arraylike: ... - def set_order(self, order: builtins.int, *, recursive: bool = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/quaternion.py b/src/polymath/quaternion.py index 5abe13d..20aea12 100755 --- a/src/polymath/quaternion.py +++ b/src/polymath/quaternion.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/quaternion.py: Quaternion subclass of PolyMath base class ########################################################################################## +"""The :class:`~polymath.Quaternion` subclass, representing rotations as quaternions. + +A Quaternion is a :class:`~polymath.Vector` whose numerator shape is fixed at ``(4,)``, +holding one scalar part and a three-element vector part. Quaternion multiplication +composes rotations, and this class converts between quaternions, 3x3 rotation matrices +(:class:`~polymath.Matrix3`), and rotation vectors. +""" import numpy as np @@ -36,7 +43,7 @@ def as_quaternion(arg, *, recursive=True): """Convert the argument to a Quaternion if possible. Parameters: - arg (object): The object to convert to Quaternion. + arg (QuaternionLike): The object to convert to Quaternion. recursive (bool, optional): If True, derivatives will also be converted. Returns: @@ -64,22 +71,19 @@ def from_parts(scalar, vector, *, recursive=True): """Construct a Quaternion from separate scalar and vector components. Parameters: - scalar (Scalar or None): The scalar part of the quaternion. If None, the - associated component is filled with zeros. The scalar and vector are - automatically broadcast to compatible shapes. - vector (Vector3 or None): The vector part of the quaternion. If None, the - associated components are filled with zeros. The scalar and vector are - automatically broadcast to compatible shapes. + scalar (ScalarLike | None): The scalar part of the quaternion. If None, the + associated component is filled with zeros. + vector (Vector3Like | None): The vector part of the quaternion. If None, the + associated components are filled with zeros. The `scalar` and `vector` + inputs are automatically broadcast to compatible shapes. recursive (bool, optional): True to include derivatives. If True, derivatives - from both scalar and vector are combined in the resulting quaternion. + from both `scalar` and `vector` are combined in the resulting quaternion. Returns: - Quaternion: A new Quaternion constructed from the scalar and vector parts. - The quaternion has shape [s, vx, vy, vz] where s is the scalar part and - (vx, vy, vz) are the vector components. + Quaternion: A new Quaternion constructed from `scalar` and `vector`. Raises: - ValueError: If scalar and vector denominators are incompatible. + ValueError: If the denominators of `scalar` and `vector` are incompatible. """ # Fill in missing values @@ -140,20 +144,19 @@ def to_parts(self, *, recursive=True): recursive (bool, optional): If True, derivatives will also be split. Returns: - tuple: A tuple containing (scalar_part, vector_part) where scalar_part is a - Scalar and vector_part is a Vector3. + tuple[Scalar, Vector3]: The scalar and vector parts of this Quaternion. """ - return (self.extract_numer(0, 0, Scalar, recursive=recursive), - self.slice_numer(0, 1, 4, Vector3, recursive=recursive)) + return (self.extract_numer(0, 0, classes=Scalar, recursive=recursive), + self.slice_numer(0, 1, 4, classes=Vector3, recursive=recursive)) @staticmethod def from_rotation(angle, vector, *, recursive=True): """Construct a Quaternion for an angular rotation about an axis vector. Parameters: - angle (Scalar): The angle of rotation in radians. - vector (Vector3): The axis vector to rotate around. + angle (ScalarLike): The angle of rotation in radians. + vector (Vector3Like): The axis vector to rotate around. recursive (bool, optional): If True, derivatives will be included. Returns: @@ -181,9 +184,8 @@ def to_rotation(self, *, recursive=True): recursive (bool, optional): If True, derivatives will be included. Returns: - tuple: A tuple containing (angle, unit_vector) where angle is a Scalar - representing the rotation angle in radians, and unit_vector is a Vector3 - representing the rotation axis. + tuple[Scalar, Vector3]: The rotation angle in radians and the unit vector + around which the rotation is performed. """ (cos_half_angle, vector) = self.to_parts(recursive=recursive) @@ -195,8 +197,7 @@ def to_rotation(self, *, recursive=True): def conj(self, *, recursive=True): """The complex conjugate of this quaternion. - The conjugate of a quaternion [s, v] is [s, -v], where s is the scalar part and v - is the vector part. + The conjugate of a quaternion ``[s, v]`` is ``[s, -v]``. Parameters: recursive (bool, optional): If True, derivatives will also be conjugated. @@ -230,16 +231,14 @@ def to_matrix3(self, *, recursive=True, partials=False): recursive (bool, optional): If True, the returned Matrix3 will contain derivatives of the Quaternion. These are represented as Matrix objects, not Matrix3 objects, because they are not unitary. - partials (bool, optional): If True, instead of returning just the - Matrix3, return a tuple containing the Matrix3 and its partial - derivatives with respect to the components of the quaternion. + partials (bool, optional): If True, also return the partial derivatives with + respect to the components of the quaternion. Returns: - Matrix3 or tuple: If partials is False, returns a Matrix3 representing the - rotation. If partials is True, returns a tuple of (Matrix3, - partial_derivatives) where partial_derivatives is a Matrix with numerator - shape (3, 3) and denominator shape (4,), representing the derivative of each - matrix element with respect to each quaternion component. + Matrix3 | tuple[Matrix3, Matrix]: The Matrix3 of the rotation, optionally + followed by a Matrix with numerator shape (3,3) and denominator shape (4,), + representing the derivative of each matrix element with respect to each + quaternion component. Raises: ValueError: If this Quaternion has denominator axes. @@ -298,30 +297,30 @@ def to_matrix3(self, *, recursive=True, partials=False): obj = Matrix3(values, pmask) if (recursive and self._derivs) or partials: -# Before scaling, but assuming a unit quaternion... -# values[...,0,0] = 1. - 2.*(yy + zz) -# values[...,0,1] = 2.*(xy - sz) -# values[...,0,2] = 2.*(xz + sy) -# values[...,1,0] = 2.*(xy + sz) -# values[...,1,1] = 1. - 2.*(xx + zz) -# result[...,1,2] = 2.*(yz - sx) -# values[...,2,0] = 2.*(xz - sy) -# values[...,2,1] = 2.*(yz + sx) -# values[...,2,2] = 1. - 2.*(xx + yy) -# -# dm_dq = np.zeros(self._shape + (3,3,4)) -# dm_dq[...,0,0,:] = 2*( 0, 0,-2y,-2z) -# dm_dq[...,0,1,:] = 2*(-z, y, x, -s) -# dm_dq[...,0,2,:] = 2*( y, z, s, x) -# dm_dq[...,1,0,:] = 2*( z, y, x, s) -# dm_dq[...,1,1,:] = 2*( 0,-2x, 0,-2z) -# dm_dq[...,1,2,:] = 2*(-x, -s, z, y) -# dm_dq[...,2,0,:] = 2*(-y, z, -s, x) -# dm_dq[...,2,1,:] = 2*( x, s, z, y) -# dm_dq[...,2,2,:] = 2*( 0,-2x,-2y, 0) -# -# (s,x,y,z) have already been scaled by sqrt(2). Scale by another -# factor of sqrt(2) when done. + # Before scaling, but assuming a unit quaternion... + # values[...,0,0] = 1. - 2.*(yy + zz) + # values[...,0,1] = 2.*(xy - sz) + # values[...,0,2] = 2.*(xz + sy) + # values[...,1,0] = 2.*(xy + sz) + # values[...,1,1] = 1. - 2.*(xx + zz) + # result[...,1,2] = 2.*(yz - sx) + # values[...,2,0] = 2.*(xz - sy) + # values[...,2,1] = 2.*(yz + sx) + # values[...,2,2] = 1. - 2.*(xx + yy) + # + # dm_dq = np.zeros(self._shape + (3,3,4)) + # dm_dq[...,0,0,:] = 2*( 0, 0,-2y,-2z) + # dm_dq[...,0,1,:] = 2*(-z, y, x, -s) + # dm_dq[...,0,2,:] = 2*( y, z, s, x) + # dm_dq[...,1,0,:] = 2*( z, y, x, s) + # dm_dq[...,1,1,:] = 2*( 0,-2x, 0,-2z) + # dm_dq[...,1,2,:] = 2*(-x, -s, z, y) + # dm_dq[...,2,0,:] = 2*(-y, z, -s, x) + # dm_dq[...,2,1,:] = 2*( x, s, z, y) + # dm_dq[...,2,2,:] = 2*( 0,-2x,-2y, 0) + # + # (s,x,y,z) have already been scaled by sqrt(2). Scale by another + # factor of sqrt(2) when done. m = np.zeros(self._shape + (3, 3, 4)) m[..., 1, 1, 1] = m[..., 2, 2, 1] = -2 * x @@ -345,14 +344,14 @@ def to_matrix3(self, *, recursive=True, partials=False): dm_dq = Matrix(m, pmask, drank=1) -# We also have to deal with the unit() applied to the quaternion at the -# begininning. Let p be the un-normalized quaternion, q the unit version. -# q = p / p_norm -# where -# qnorm = sqrt(q0**2 + q1**2 + a2**2 + q3**2) -# -# dq0/dp0 = (p1**2 + p2**2 + p3**2) / pnorm**3 -# dq0/dp0 = -p0*p1 / pnorm**3 + # We also have to deal with the unit() applied to the quaternion at the + # begininning. Let p be the un-normalized quaternion, q the unit version. + # q = p / p_norm + # where + # qnorm = sqrt(q0**2 + q1**2 + a2**2 + q3**2) + # + # dq0/dp0 = (p1**2 + p2**2 + p3**2) / pnorm**3 + # dq0/dp0 = -p0*p1 / pnorm**3 dq_dp = np.zeros(self._shape + (4, 4)) for i in range(4): @@ -377,16 +376,16 @@ def from_matrix3(matrix, *, recursive=True): """Convert a Matrix3 to a Quaternion. Parameters: - matrix (Matrix3): The rotation matrix to convert. The matrix should be a - proper rotation matrix (orthogonal with determinant +1), though the - method will work with any 3x3 matrix. + matrix (Matrix3Like): The rotation matrix to convert. The matrix should be a + proper rotation matrix (orthogonal with determinant +1), though the method + will work with any 3x3 matrix. recursive (bool, optional): If True, the returned Quaternion will include derivatives. Returns: - Quaternion: A quaternion representing the same rotation as the input matrix. - The quaternion is normalized such that quaternions q and -q represent the - same rotation. + Quaternion: A unit quaternion representing the same rotation as the input + matrix. Because *q* and *-q* represent the same rotation, the sign is chosen + so that the component of largest magnitude is positive. Notes: The derivatives are exact for any matrix derivative that is tangent to the @@ -552,8 +551,8 @@ def __mul__(self, /, arg, *, recursive=True): """The product of this quaternion and another object. Parameters: - arg: The object to multiply with this quaternion. If arg is a Vector3, it is - automatically converted to a Quaternion with zero scalar part before + arg (Any): The object to multiply with this quaternion. If `arg` is a Vector3, + it is automatically converted to a Quaternion with zero scalar part before multiplication. For other Qube subclasses, the default multiplication operator is used. recursive (bool, optional): If True, the returned object will include @@ -563,7 +562,8 @@ def __mul__(self, /, arg, *, recursive=True): Quaternion: The product of this quaternion and the argument. Raises: - ValueError: If both this quaternion and arg have denominators. + ValueError: If both this quaternion and `arg` have denominators or if shapes + are incompatible. """ # Use default operator for anything but a Qube subclass @@ -596,7 +596,7 @@ def __mul__(self, /, arg, *, recursive=True): a_values = a_values.reshape(a._shape + b._drank * (1,) + (4,)) b_values = np.moveaxis(b_values, -b._drank - 1, -1) - new_values = Quaternion.mul_values(a_values, b_values) + new_values = Quaternion._mul_values(a_values, b_values) if a._drank or b._drank: new_values = np.moveaxis(new_values, -1, -(a._drank + b._drank + 1)) @@ -626,7 +626,7 @@ def __mul__(self, /, arg, *, recursive=True): return obj @staticmethod - def mul_values(a, b): + def _mul_values(a, b): """Multiply two quaternion arrays element-wise. Parameters: @@ -667,7 +667,7 @@ def __rmul__(self, /, arg, *, recursive=True): """The product of another object and this quaternion. Parameters: - arg: The object to multiply with this quaternion. + arg (Any): The object to multiply with this quaternion. recursive (bool, optional): If True, the returned object will include derivatives. @@ -687,7 +687,7 @@ def __truediv__(self, /, arg, *, recursive=True): """The result of dividing this quaternion by another object. Parameters: - arg: The object to divide this quaternion by. + arg (Any): The object to divide this quaternion by. recursive (bool, optional): If True, the returned object will include derivatives. @@ -714,8 +714,8 @@ def reciprocal(self, *, recursive=True): """The reciprocal of this quaternion. Parameters: - recursive (bool, optional): True to return the derivatives of the - reciprocal too; otherwise, derivatives are removed. + recursive (bool, optional): True to return the derivatives of the reciprocal + too; otherwise, derivatives are removed. Returns: Quaternion: The quaternion reciprocal (conjugate divided by norm squared). @@ -729,7 +729,7 @@ def identity(self): This method overrides :meth:`~Qube.identity` for the base class. Returns: - Quaternion: A read-only identity quaternion [1,0,0,0]. + Quaternion: A read-only identity quaternion, ``[1, 0, 0, 0]``. """ return Quaternion(np.array([1., 0., 0., 0.])).as_readonly() @@ -778,32 +778,39 @@ def from_euler(ai, aj, ak, axes='rzxz'): """Construct a Quaternion from Euler rotation angles. Parameters: - ai (scalar): First rotation angle in radians. - aj (scalar): Second rotation angle in radians. - ak (scalar): Third rotation angle in radians. - axes (str, optional): One of 24 axis sequences as string or encoded tuple. + ai (ScalarLike): First rotation angle in radians. + aj (ScalarLike): Second rotation angle in radians. + ak (ScalarLike): Third rotation angle in radians. + axes (str | tuple[int, int, int, int], optional): One of 24 axis sequences as + a string or an encoded tuple. Returns: Quaternion: A quaternion representing the specified rotation. Notes: - A triple of Euler angles can be applied/interpreted in 24 ways, which can be - specified using a 4-character string or encoded 4-tuple: - - * Axes 4-string*: e.g. 'sxyz' or 'ryxy' - - First character: rotations are applied to 's'tatic or 'r'otating frame - - Remaining characters: successive rotation axis 'x', 'y', or 'z' - - * Axes 4-tuple*: e.g. (0, 0, 0, 0) or (1, 1, 1, 1) - - inner axis: code of axis ('x':0, 'y':1, 'z':2) of rightmost matrix. - - parity: even (0) if inner axis 'x' is followed by 'y', 'y' is - followed by 'z', or 'z' is followed by 'x'. Otherwise odd (1). - - repetition: first and last axis are same (1) or different (0). - - frame: rotations are applied to static (0) or rotating (1) frame. - - >>> q = quaternion_from_euler(1, 2, 3, 'ryxz') - >>> numpy.allclose(q, [0.435953, 0.310622, -0.718287, 0.444435]) - True + A triple of Euler angles can be applied or interpreted in 24 ways, which can + be specified using a four-character string or an encoded four-tuple. + + A four-character string such as ``'sxyz'`` or ``'ryxy'``: + + * First character: rotations are applied to a static (``'s'``) or rotating + (``'r'``) frame. + * Remaining characters: successive rotation axes ``'x'``, ``'y'``, or ``'z'``. + + A four-tuple such as ``(0, 0, 0, 0)`` or ``(1, 1, 1, 1)``: + + * Inner axis: code of the axis (``'x'``: 0, ``'y'``: 1, ``'z'``: 2) of the + rightmost matrix. + * Parity: even (0) if the inner axis ``'x'`` is followed by ``'y'``, ``'y'`` + is followed by ``'z'``, or ``'z'`` is followed by ``'x'``; otherwise odd + (1). + * Repetition: the first and last axes are the same (1) or different (0). + * Frame: rotations are applied to a static (0) or rotating (1) frame. + + Examples: + >>> q = Quaternion.from_euler(1, 2, 3, 'ryxz') + >>> np.allclose(q, [0.435953, 0.310622, -0.718287, 0.444435]) + True """ ai = Scalar.as_scalar(ai) @@ -870,14 +877,17 @@ def to_euler(self, axes='rzxz'): """Extract Euler angles from this quaternion. Parameters: - axes (str, optional): One of 24 axis sequences as string or encoded tuple. + axes (str | tuple[int, int, int, int], optional): One of 24 axis sequences as + a string or an encoded tuple. Returns: - tuple: A tuple of three Scalars containing the Euler angles. + tuple[Scalar, Scalar, Scalar]: The three Euler angles in radians, each in the + range 0 to 2 pi. Notes: - This method uses the to_matrix3() method, and then from_matrix3() method - on the result. + This method converts this quaternion to a Matrix3 using :meth:`to_matrix3` + and then calls :meth:`~polymath.Matrix3.to_euler` on the result. Derivatives + are not included. """ return self.to_matrix3().to_euler(axes) @@ -887,17 +897,18 @@ def from_euler_via_matrix(ai, aj, ak, axes='rzxz'): """Construct a Quaternion from Euler angles via an intermediate Matrix3. Parameters: - ai (scalar): First rotation angle in radians. - aj (scalar): Second rotation angle in radians. - ak (scalar): Third rotation angle in radians. - axes (str, optional): One of 24 axis sequences as string or encoded tuple. + ai (ScalarLike): First rotation angle in radians. + aj (ScalarLike): Second rotation angle in radians. + ak (ScalarLike): Third rotation angle in radians. + axes (str | tuple[int, int, int, int], optional): One of 24 axis sequences as + a string or an encoded tuple. Returns: Quaternion: A quaternion representing the specified rotation. Notes: - This method uses the Matrix3.from_euler() method, and then converts - the result to a Quaternion. + This method calls :meth:`~polymath.Matrix3.from_euler` and then converts the + result to a Quaternion. """ return Quaternion.from_matrix3(Matrix3.from_euler(ai, aj, ak, axes)) diff --git a/src/polymath/quaternion.pyi b/src/polymath/quaternion.pyi deleted file mode 100644 index d30a138..0000000 --- a/src/polymath/quaternion.pyi +++ /dev/null @@ -1,57 +0,0 @@ -########################################################################################## -# polymath/quaternion.pyi -########################################################################################## -"""Type stub for :mod:`polymath.quaternion`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -from typing import Any - -from numpy.typing import NDArray - -from polymath.qube import _Arraylike, _ShapeOrTuple -from polymath.vector import Vector - -__all__ = ['Quaternion'] - -class Quaternion(Vector): - IDENTITY: Quaternion - MASKED: Quaternion - XAXIS: Quaternion - YAXIS: Quaternion - ZAXIS: Quaternion - ZERO: Quaternion - def __mul__(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __rmul__(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __truediv__(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - @staticmethod - def as_quaternion(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def conj(self, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_euler(ai: Any, aj: Any, ak: Any, axes: str = ...) -> _Arraylike: ... - @staticmethod - def from_euler_via_matrix(ai: Any, aj: Any, ak: Any, - axes: str = ...) -> _Arraylike: ... - @staticmethod - def from_matrix3(matrix: _Arraylike, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_parts(scalar: Any, vector: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_rotation(angle: _Arraylike, vector: _Arraylike, *, - recursive: bool = ...) -> _Arraylike: ... - def identity(self) -> _Arraylike: ... - @staticmethod - def mul_values(a: NDArray[Any], b: NDArray[Any]) -> NDArray[Any]: ... - def reciprocal(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def to_euler(self, axes: str = ...) -> _ShapeOrTuple: ... - def to_matrix3(self, *, recursive: bool = ..., - partials: bool = ...) -> _Arraylike | _ShapeOrTuple: ... - def to_parts(self, *, recursive: bool = ...) -> _ShapeOrTuple: ... - def to_rotation(self, *, recursive: bool = ...) -> _ShapeOrTuple: ... - -########################################################################################## diff --git a/src/polymath/qube.py b/src/polymath/qube.py index 5ac8f62..6bc6cd1 100644 --- a/src/polymath/qube.py +++ b/src/polymath/qube.py @@ -1,13 +1,32 @@ ########################################################################################## # polymath/qube.py: Base class for all PolyMath subclasses. ########################################################################################## +"""The :class:`~polymath.Qube` base class, from which every PolyMath class derives. + +A Qube wraps a NumPy array with a boolean mask, an optional :class:`~polymath.Unit`, and +an optional dictionary of derivatives. Its axes are divided into leading array axes, given +by :attr:`~polymath.Qube.shape`, and trailing item axes, which are further split into a +numerator and a denominator so that partial derivatives can be represented. + +This module holds only what defines the object: the class constants, the constructor and +its supporting construction path, low-level value and mask access, the properties, and the +cache. Every other method is defined in :mod:`polymath.extensions` and bound onto the +class at import time. +""" import math import numpy as np import numbers +from typing import TYPE_CHECKING + from polymath.unit import Unit +# polymath.typedefs imports Qube, so importing it here at runtime would be +# circular. These names are needed only for the property annotations below. +if TYPE_CHECKING: + from polymath.typedefs import MaskType, ValsType + __all__ = ['Qube'] # Concrete numeric types, tested ahead of the numbers ABCs. An isinstance() check against @@ -64,8 +83,8 @@ class Qube: the numerator. As a result, the aforementioned partial derivatives can still be represented by a Vector3 object. - Properties: - shape (tuple): + Attributes: + shape (tuple[int, ...]): The leading axes of the object, i.e., those that are not considered part of the items. rank (int): @@ -74,27 +93,27 @@ class Qube: The number of numerator axes associated with the items. drank (int): The number of denominator axes associated with the items. - item (tuple): + item (tuple[int, ...]): The shape of the individual items. - numer (tuple): + numer (tuple[int, ...]): The shape of the numerator items. - denom (tuple): + denom (tuple[int, ...]): The shape of the denominator items. - values (numpy.ndarray, float, int, or bool): + values (numpy.ndarray | float | int | bool): The object's data, with shape object.shape + object.item. If the object has a unit, then the values are in default units (km, sec, etc.) rather than in the specified unit. - vals (numpy.ndarray, float, int, or bool): + vals (numpy.ndarray | float | int | bool): Alternative name for `values`. - mask (numpy.ndarray or bool): + mask (numpy.ndarray | bool): The array's mask. A scalar False means the object is entirely unmasked; a scalar True means it is entirely masked. Otherwise, it is a boolean array of shape object.shape. - unit (Unit or None): + unit (Unit | None): The unit of the array, if any. None indicates no unit. - derivs (dict): + derivs (dict[str, Qube]): A dictionary of the names and values of any derivatives, each represented by - additional PolyMath object. + an additional PolyMath object. readonly (bool): True if the object cannot (or at least should not) be modified. A determined user may be able to alter a read-only object, but the API makes this more @@ -152,7 +171,18 @@ class Qube: _DERIV_CLASS = None def __new__(subtype, *values, **keywords): - """Create a new, un-initialized object given a Qube subclass.""" + """Create a new, un-initialized object given a Qube subclass. + + Parameters: + subtype (type): The Qube subclass to instantiate. + *values (Any): Ignored; accepted so that the signature matches + :meth:`Qube.__init__`. + **keywords (Any): Ignored; accepted so that the signature matches + :meth:`Qube.__init__`. + + Returns: + Qube: A new, un-initialized instance of `subtype`. + """ return object.__new__(subtype) @@ -162,27 +192,27 @@ def __init__(self, arg, mask=False, *, derivs={}, # noqa: B006 # {} and None """Default constructor. Parameters: - arg (Qube, array-like, float, int, or bool): An object to define the numeric - value(s) of the returned object. If this object is read-only, then the - returned object will be entirely read-only. Otherwise, the object will be - read-writable. The values are generally given in standard units of km, - seconds and radians, regardless of the specified unit. - mask (Boolean, array-like, or bool, optional): The mask for the object. Use - None to copy the mask from the example object. False (the default) leaves - the object un-masked. - derivs (dict, optional): Derivatives represented as PolyMath objects. Use None - to make a copy of the derivs attribute of the example object, or {} (the - default) for no derivatives. All derivatives are broadcasted to the shape - of the object if necessary. - unit (Unit, optional): The unit of the object. Use None to infer the unit from - the example object; use False to suppress the unit. - nrank (int, optional): The number of numerator axes in the returned object; - None to derive the rank from the input data and/or the subclass. - drank (int, optional): The number of denominator axes in the returned object; - None to derive it from the input data and/or the subclass. - example (Qube, optional): Another Qube object from which to copy any input - arguments except derivs that have not been explicitly specified. - default (array-like, float, int, or bool): Value to use where masked. This is + arg (QubeLike): An object to define the numeric value(s) of the returned + object. If this object is read-only, then the returned object will be + entirely read-only. Otherwise, the object will be read-writable. The + values are generally given in standard units of km, seconds and radians, + regardless of the specified unit. + mask (BooleanLike | None, optional): The mask for the object. Use None to + copy the mask from the example object. False (the default) leaves the + object un-masked. + derivs (dict[str, Qube] | None, optional): Derivatives represented as + PolyMath objects. Use None to make a copy of the derivs attribute of the + example object, or {} (the default) for no derivatives. All derivatives + are broadcasted to the shape of the object if necessary. + unit (Unit | bool | None, optional): The unit of the object. Use None to + infer the unit from the example object; use False to suppress the unit. + nrank (int | None, optional): The number of numerator axes in the returned + object; None to derive the rank from the input data and/or the subclass. + drank (int | None, optional): The number of denominator axes in the returned + object; None to derive it from the input data and/or the subclass. + example (Qube | None, optional): Another Qube object from which to copy any + input arguments except derivs that have not been explicitly specified. + default (QubeLike | None, optional): Value to use where masked. This is typically a constant that will not "break" most arithmetic calculations. If it is an array, it must be of the same shape as the items. op (str, optional): Name of an operation to include in an error message if @@ -191,10 +221,9 @@ def __init__(self, arg, mask=False, *, derivs={}, # noqa: B006 # {} and None Raises: TypeError: If the data type of `arg` or `mask` is invalid. TypeError: If `example` is not an instance of Qube. - ValueError: If the shape of `mask` is incompatible with object. + ValueError: If the shape of `mask` is incompatible with that of the object. TypeError: If `unit` is specified but is disallowed by the Qube subclass. - ValueError: If `derivs` are specified but are disallowed by the Qube - subclass. + ValueError: If `derivs` are specified but are disallowed by the Qube subclass. ValueError: If `nrank` is incompatible with the Qube subclass. ValueError: If `drank` is specified but the Qube subclass disallows derivatives. @@ -365,8 +394,9 @@ def prefer_builtins(status=None): type, rather than a Qube subclass, if possible. Parameters: - status (bool, optional): True to favor Python builtin types; False otherwise. - Omit this input to leave the global setting unchanged (but return it). + status (bool | None, optional): True to favor Python builtin types; False + otherwise. Omit this input to leave the global setting unchanged (but + return it). Returns: bool: True if builtins are globally preferred; False otherwise. @@ -382,11 +412,11 @@ def as_builtin(self, masked=None): can be done without loss of information. Parameters: - masked (float, int, or bool, optional): Value to return if the shape of this - object is () and it is masked. + masked (float | int | bool | None, optional): Value to return if the shape of + this object is () and it is masked. Returns: - (Qube, float, int, bool, or None): This object's `values` attribute if its + Qube | float | int | bool | None: This object's `values` attribute if its shape is () and it is unmasked; the value of `masked` if the shape is () and it is masked; otherwise, this object. """ @@ -445,8 +475,8 @@ def _transfer_attrs(source, dest, *, added_attrs=True): Parameters: source (Qube): The object to copy from. dest (Qube): The object to copy onto. - added_attrs (bool, optional): True to copy the attributes added by - add_attr(), which are transferred by reference; False to omit them. + added_attrs (bool, optional): True to copy the attributes added by add_attr(), + which are transferred by reference; False to omit them. """ for attr in Qube._TRANSFERABLE_ATTRS: @@ -470,8 +500,8 @@ def clone(self, *, recursive=True, preserve=(), retain_cache=False): Parameters: recursive (bool, optional): True to clone the derivatives of this object; False to ignore them. - preserve (list, optional): Name(s) of derivatives to include even if - `recursive` is False. + preserve (list[str] | tuple[str, ...] | set[str], optional): Name(s) of + derivatives to include even if `recursive` is False. retain_cache (bool, optional): True to retain cache except "unshrunk" and "wod"; False to return clone with an empty cache. @@ -510,8 +540,8 @@ def _clone(self, *, recursive, preserve, retain_cache, added_attrs): Parameters: recursive (bool): True to clone the derivatives of this object; False to ignore them. - preserve (list): Name(s) of derivatives to include even if `recursive` is - False. + preserve (list[str] | tuple[str, ...] | set[str]): Name(s) of derivatives to + include even if `recursive` is False. retain_cache (bool): True to retain cache except "unshrunk" and "wod"; False to return clone with an empty cache. added_attrs (bool): True to carry the attributes added by add_attr() onto the @@ -561,12 +591,13 @@ def zeros(cls, shape, dtype='float', *, numer=None, denom=(), mask=False): """New object of this class and shape, filled with zeros. Parameters: - shape (tuple): Shape of the object. + shape (tuple[int, ...]): Shape of the object. dtype (str, optional): One of "bool", "int", or "float", defining the data type. Ignored if `cls` has a default dtype. - numer (tuple, optional): Numerator shape; None to use default for `cls`. - denom (tuple, optional): Denominator shape. - mask (array-like or bool, optional): Mask to apply. + numer (tuple[int, ...] | None, optional): Numerator shape; None to use default + for `cls`. + denom (tuple[int, ...], optional): Denominator shape. + mask (BooleanLike, optional): Mask to apply. Returns: Qube: The new object. @@ -585,12 +616,13 @@ def ones(cls, shape, dtype='float', *, numer=None, denom=(), mask=False): """New object of this class and shape, filled with ones. Parameters: - shape (tuple): Shape of the object. + shape (tuple[int, ...]): Shape of the object. dtype (str, optional): One of "bool", "int", or "float", defining the data type. Ignored if `cls` has a default dtype. - numer (tuple, optional): Numerator shape; None to use default for `cls`. - denom (tuple, optional): Denominator shape. - mask (array-like or bool, optional): Mask to apply. + numer (tuple[int, ...] | None, optional): Numerator shape; None to use default + for `cls`. + denom (tuple[int, ...], optional): Denominator shape. + mask (BooleanLike, optional): Mask to apply. Returns: Qube: The new object. @@ -624,15 +656,16 @@ def _new_from_parts(cls, values, mask=False, *, nrank, drank=0, unit=None, Derivatives are never carried over; insert them into the returned object instead. Parameters: - values (numpy.ndarray, float, int, or bool): The values of the new object. - mask (numpy.ndarray or bool, optional): The mask of the new object. + values (numpy.ndarray | float | int | bool): The values of the new object. + mask (numpy.ndarray | bool, optional): The mask of the new object. nrank (int): The number of numerator axes at the end of `values`. drank (int, optional): The number of denominator axes at the end of `values`. - unit (Unit, optional): The unit of the new object; None for unitless. - example (Qube, optional): An object from which to take the default value when - its item shape and dtype match those of the new object, and from which to - take the products of the shape and of the item shape when those match. - It is used only to avoid repeating work and never changes the result. + unit (Unit | None, optional): The unit of the new object; None for unitless. + example (Qube | None, optional): An object from which to take the default + value when its item shape and dtype match those of the new object, and + from which to take the products of the shape and of the item shape when + those match. It is used only to avoid repeating work and never changes the + result. Returns: Qube: The new object, without derivatives. @@ -720,13 +753,12 @@ def _default_for(cls, item, drank, dtype): """The default value for an object of this class, item shape and dtype. Parameters: - cls (type): Qube subclass. - item (tuple): Shape of the items. + item (tuple[int, ...]): Shape of the items. drank (int): The number of denominator axes. dtype (str): One of "float", "int", or "bool". Returns: - (numpy.ndarray, float, int, or bool): The value to use where masked. + numpy.ndarray | float | int | bool: The value to use where masked. """ if hasattr(cls, '_DEFAULT_VALUE') and drank == 0: @@ -740,21 +772,22 @@ def _default_for(cls, item, drank, dtype): @classmethod def filled(cls, shape, fill=0, *, numer=None, denom=(), mask=False): - """Internal object of this class and shape, filled with a constant. + """New object of this class and shape, filled with a constant. Parameters: - shape (tuple): Shape of the object. - fill (array-like, float, int, or bool, optional): The constant value for each - item. It must be compatible with the item shape of `cls`. - numer (tuple, optional): Numerator shape; None to use default for `cls`. - denom (tuple, optional): Denominator shape. - mask (array-like or bool, optional): Mask to apply. + shape (tuple[int, ...]): Shape of the object. + fill (QubeLike, optional): The constant value for each item. It must be + compatible with the item shape of `cls`. + numer (tuple[int, ...] | None, optional): Numerator shape; None to use default + for `cls`. + denom (tuple[int, ...], optional): Denominator shape. + mask (BooleanLike, optional): Mask to apply. Returns: Qube: The new object. Raises: - ValueError: If `fill` is not compatible with the `cls`. + ValueError: If `fill` is not compatible with the item shape of `cls`. """ # Create example object with shape == () @@ -787,18 +820,19 @@ def _set_values(self, values, mask=None, *, antimask=None, retain_cache=False): The read-only status of the object is defined by that of the given value. Parameters: - values (array-like, float, int, or bool): New values. - mask (array-like or bool, optional): New mask. - antimask (array-like or bool, optional): If provided, then only the array + values (numpy.ndarray | float | int | bool): New values, with the same shape + as the current values. + mask (numpy.ndarray | bool | None, optional): New mask; None to leave the + mask unchanged. + antimask (numpy.ndarray | None, optional): If provided, then only the array locations associated with the antimask are modified. - retain_cache (bool, optional): If True, the cache values are retained except - for "unshrunk". + retain_cache (bool, optional): If True and `mask` is None, the cache values + are retained except for "unshrunk". Returns: Qube: This object, updated. Raises: - TypeError: If the type of `values` or `mask` is invalid. ValueError: If the shape of `values`, `mask`, or `antimask` is invalid. """ @@ -866,8 +900,7 @@ def _new_values(self): This means "unshrunk" will be deleted from the cache if present. """ - if 'unshrunk' in self._cache: - del self._cache['unshrunk'] + _ = self._cache.pop('unshrunk', None) def _set_mask(self, mask, *, antimask=None, check=False): """Low-level method to update the mask of an array. @@ -875,8 +908,8 @@ def _set_mask(self, mask, *, antimask=None, check=False): The read-only status of the object will be preserved. Parameters: - mask (array-like or bool, optional): New mask. - antimask (array-like or bool, optional): If provided, then only the array + mask (BooleanLike): New mask. + antimask (numpy.ndarray | None, optional): If provided, then only the array locations associated with the antimask are modified. check (bool, optional): True to check for an array containing all False values, and if so, replace it with a single value of False. @@ -920,20 +953,18 @@ def _set_mask(self, mask, *, antimask=None, check=False): ###################################################################################### @property - def values(self): + def values(self) -> 'ValsType': """The value of this object as a numpy.ndarray, float, int, or bool.""" - return self._values @property - def vals(self): + def vals(self) -> 'ValsType': """The value of this object as a numpy.ndarray, float, int, or bool.""" - - return self._values # Handy shorthand + return self._values @property - def mvals(self): - """This object as a NumPy ma.MaskedArray.""" + def mvals(self) -> np.ma.MaskedArray: + """This object as a numpy.ma.MaskedArray.""" # Deal with a scalar if self._is_scalar: @@ -959,13 +990,12 @@ def mvals(self): return np.ma.MaskedArray(self._values, mask) @property - def mask(self): - """The boolean mask of this object as a NumPy.ndarray or bool.""" - + def mask(self) -> 'MaskType': + """The boolean mask of this object as a numpy.ndarray or bool.""" return self._mask @property - def antimask(self): + def antimask(self) -> 'MaskType': """The inverse of the mask of this object, True wherever an element is valid.""" if not Qube._DISABLE_CACHE and 'antimask' in self._cache: @@ -982,109 +1012,92 @@ def antimask(self): return antimask @property - def default(self): + def default(self) -> np.ndarray | float | int | bool: """The default element value for this object.""" - return self._default @property - def unit_(self): + def unit_(self) -> Unit | None: """The Unit of this object.""" - return self._unit @property - def units(self): + def units(self) -> Unit | None: """The Unit of this object; alternative name for `unit_`.""" - return self._unit @property - def derivs(self): + def derivs(self) -> dict[str, 'Qube']: """The dictionary of derivatives of this object.""" - return self._derivs @property - def shape(self): + def shape(self) -> tuple[int, ...]: """The shape of this object as a tuple.""" - return self._shape @property - def ndims(self): + def ndims(self) -> int: """The number of dimensions in this object (excluding items).""" - - return self._ndims # alternative name + return self._ndims @property - def ndim(self): + def ndim(self) -> int: """The number of dimensions in this object (excluding items).""" - return self._ndims @property - def rank(self): - """The rank of this object.""" - + def rank(self) -> int: + """The rank of this object (numerators + denominators).""" return self._rank @property - def nrank(self): - """The rank of the element numerator in this object.""" - + def nrank(self) -> int: + """The rank of the element numerator.""" return self._nrank @property - def drank(self): - """The rank of the element denominator in this object.""" - + def drank(self) -> int: + """The rank of the element denominator.""" return self._drank @property - def item(self): - """The shape of the elements in this object as a tuple.""" - + def item(self) -> tuple[int, ...]: + """The shape of the elements in this object.""" return self._item @property - def numer(self): - """The shape of the element numerator in this object as a tuple.""" - + def numer(self) -> tuple[int, ...]: + """The shape of the element numerator.""" return self._numer @property - def denom(self): - """The shape of the element denominator in this object as a tuple.""" - + def denom(self) -> tuple[int, ...]: + """The shape of the element denominator.""" return self._denom @property - def size(self): - """The number of elements in this object's shape.""" - + def size(self) -> int: + """The total number of elements in the shape.""" return self._size @property - def isize(self): - """The number of components in this object's items.""" - + def isize(self) -> int: + """The number of components in the individual items.""" return self._isize @property - def nsize(self): - """The number of numerator components in this object's items.""" - + def nsize(self) -> int: + """The number of numerator components in the items.""" return self._nsize @property - def dsize(self): - """The number of denominator components in this object's items.""" - + def dsize(self) -> int: + """The number of denominator components in the items.""" return self._dsize @property - def readonly(self): + def readonly(self) -> bool: """True if this object is read-only; False otherwise.""" return self._readonly @@ -1101,6 +1114,10 @@ def _clear_cache(self): def _find_corners(self): """Update the corner indices such that everything outside this defined "hypercube" is masked. + + Returns: + tuple[tuple[int, ...], tuple[int, ...]] | None: The lower and upper corner + indices, or None if the object has no array axes. """ if self._ndims == 0: @@ -1132,12 +1149,13 @@ def _find_corners(self): return (tuple(lower), tuple(upper)) @property - def corners(self): + def corners(self) -> tuple[tuple[int, ...], tuple[int, ...]] | None: """Corners of a "hypercube" that contain all the unmasked array elements. - Returns: - (tuple, tuple): The first tuple defines the lower coordinates of the unmasked - region, and the second tuple defines the upper coordinates. + The first tuple defines the lower coordinates of the unmasked, N-dimensional + region and the second defines the upper coordinates (exclusive). If every element + is masked, both tuples are zeros. The value is None if the object has no array + axes. """ if not Qube._DISABLE_CACHE and 'corners' in self._cache: @@ -1149,7 +1167,15 @@ def corners(self): @staticmethod def _slicer_from_corners(corners): - """A slice object based on corners specified as a tuple of indices.""" + """A slice object based on corners specified as a tuple of indices. + + Parameters: + corners (tuple[tuple[int, ...], tuple[int, ...]]): A tuple of two index + tuples, giving the lower and upper corner of the region. + + Returns: + tuple[slice, ...]: A tuple of slice objects, one for each axis. + """ slice_objects = [] for axis in range(len(corners[0])): @@ -1159,7 +1185,15 @@ def _slicer_from_corners(corners): @staticmethod def _shape_from_corners(corners): - """Array shape based on corner indices.""" + """Array shape based on corner indices. + + Parameters: + corners (tuple[tuple[int, ...], tuple[int, ...]]): A tuple of two index + tuples, giving the lower and upper corner of the region. + + Returns: + tuple[int, ...]: The shape of the region that the corners enclose. + """ shape = [] for axis in range(len(corners[0])): @@ -1168,8 +1202,8 @@ def _shape_from_corners(corners): return tuple(shape) @property - def _slicer(self): - """A slice object containing all the array elements inside the current corners.""" + def _slicer(self) -> tuple[slice, ...]: + """A tuple of slice objects selecting every array element inside the corners.""" if not Qube._DISABLE_CACHE and 'slicer' in self._cache: return self._cache['slicer'] @@ -1185,19 +1219,19 @@ def _slicer(self): def __repr__(self): """Express the value as a string. - The format of the returned string is `Class([value, value, ...], suffixes, ...)`, - where the quanity inside square brackets is the result of str() applied to a NumPy - ndarray. + The format of the returned string is ``Class([value, value, ...]; suffixes)``, + where the quantity inside square brackets is the result of str() applied to a + NumPy ndarray. - The suffixes are, in order... + The suffixes are, in order: * "denom=(shape)" if the object has a denominator; - * "mask" if the object has a mask - * the name of the unit of the object has a unit - * the names of all the derivatives in alphabetical order + * "mask" if any element of the object is masked; + * the name of the unit if the object has a unit; + * the names of all the derivatives in alphabetical order. Returns: - str: String representation + str: The string representation of this object. """ return self.__str__() @@ -1205,19 +1239,19 @@ def __repr__(self): def __str__(self): """Express the value as a string. - The format of the returned string is `Class([value, value, ...], suffixes, ...)`, - where the quanity inside square brackets is the result of str() applied to a NumPy - ndarray. + The format of the returned string is ``Class([value, value, ...]; suffixes)``, + where the quantity inside square brackets is the result of str() applied to a + NumPy ndarray. - The suffixes are, in order... + The suffixes are, in order: * "denom=(shape)" if the object has a denominator; - * "mask" if the object has a mask - * the name of the unit of the object has a unit - * the names of all the derivatives in alphabetical order + * "mask" if any element of the object is masked; + * the name of the unit if the object has a unit; + * the names of all the derivatives in alphabetical order. Returns: - str: String representation + str: The string representation of this object. """ suffix = [] @@ -1279,18 +1313,18 @@ def from_scalars(cls, *scalars, recursive=True, readonly=False, classes=()): subclass. Parameters: - *scalars (Qube, array-like, float, or int): - One or more Scalars or objects that can be converted to Scalars. - recursive (bool, optional): - True to construct the derivatives as the union of the derivatives of all - the components' derivatives. False to return an object without - derivatives. - readonly (bool, optional): - True to return a read-only object; False (the default) to return something - potentially writable. - classes: (class or list[class]): - A list defining the preferred class of the returned object. The first - suitable class in the list will be used; default is [Vector]. + *scalars (QubeLike): One or more Scalars or objects that can be converted to + Scalars. + recursive (bool, optional): True to construct the derivatives as the union of + the derivatives of all the components' derivatives. False to return an + object without derivatives. + readonly (bool, optional): True to return a read-only object; False (the + default) to return something potentially writable. + classes (type | list[type] | tuple[type, ...], optional): A class or list + of classes defining the preferred class of the returned object. The first + suitable class in the list will be used; if none is suitable, or if the + list is empty (the default), the returned object is an instance of this + class. Returns: Qube: A new object constructed from the inputs and using the first suitable @@ -1351,7 +1385,7 @@ class within `classes`. obj = Qube.__new__(cls) obj.__init__(new_values, new_mask, unit=new_unit, nrank=scalars[0]._nrank + 1, drank=new_drank) - obj = obj.cast(classes) + obj = obj.cast(classes=classes) # Insert derivatives if necessary if recursive and has_derivs: @@ -1378,6 +1412,9 @@ class within `classes`. readonly=readonly, classes=classes) obj.insert_derivs(new_derivs) + if readonly: + obj.as_readonly() + return obj ########################################################################################## diff --git a/src/polymath/qube.pyi b/src/polymath/qube.pyi deleted file mode 100644 index 674fdfa..0000000 --- a/src/polymath/qube.pyi +++ /dev/null @@ -1,380 +0,0 @@ -########################################################################################## -# polymath/qube.pyi -########################################################################################## -"""Type stub for :mod:`polymath.qube`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from collections.abc import Iterator -from typing import Any, ClassVar, Self, TypeAlias - -import numpy as np -from numpy.typing import NDArray - -from polymath.unit import Unit - -__all__ = ['Qube'] - -# Anything the constructors accept in place of a value: a PolyMath object, a NumPy array, -# a nested sequence, or a single number. -_Arraylike: TypeAlias = (Qube | NDArray[Any] | np.ma.MaskedArray[Any, Any] | - list[Any] | tuple[Any, ...] | float | builtins.int | bool) - -# A bare "tuple" in a docstring, which is usually but not always a shape -_ShapeOrTuple: TypeAlias = tuple[Any, ...] - -class Qube: - # Lets NumPy defer to these operators rather than its own - __array_priority__: ClassVar[builtins.int] - - # Qube compares by value and is mutable, so it is not hashable - __hash__: ClassVar[None] # type: ignore[assignment] - def __abs__(self, *, recursive: bool = ...) -> Qube: ... - def __add__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __and__(self, arg: Any) -> Any: ... - def __bool__(self) -> bool: ... - def __copy__(self) -> Self: ... - def __eq__(self, arg: object) -> Any: ... - def __float__(self) -> float: ... - def __floordiv__(self, arg: _Arraylike) -> Qube: ... - def __ge__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[misc] - def __getitem__(self, indx: Any) -> Qube: ... - def __getstate__(self) -> dict[str, Any]: ... - def __gt__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[misc] - def __iadd__(self, arg: _Arraylike) -> Qube: ... # type: ignore[misc] - def __iand__(self, arg: Any) -> Any: ... - def __ifloordiv__(self, arg: _Arraylike) -> Qube: ... - def __imod__(self, arg: _Arraylike) -> Qube: ... # type: ignore[misc] - def __imul__(self, arg: _Arraylike) -> Qube: ... # type: ignore[misc] - def __init__(self, arg: Any, mask: _Arraylike = ..., *, - derivs: dict[str, Qube] = ..., unit: Unit | None = ..., - nrank: builtins.int | None = ..., drank: builtins.int | None = ..., - example: Qube | None = ..., default: _Arraylike | None = ..., - op: str = ...) -> None: ... - def __int__(self) -> builtins.int: ... - def __invert__(self) -> Any: ... - def __ior__(self, arg: Any) -> Any: ... - def __ipow__(self, arg: _Arraylike) -> Qube: ... - def __isub__(self, arg: _Arraylike) -> Qube: ... # type: ignore[misc] - def __iter__(self) -> Iterator[Any]: ... - def __itruediv__(self, arg: _Arraylike) -> Qube: ... # type: ignore[misc] - def __ixor__(self, arg: Any) -> Any: ... - def __le__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[misc] - def __len__(self) -> builtins.int: ... - def __lt__(self, arg: _Arraylike) -> _Arraylike: ... # type: ignore[misc] - def __matmul__(self, arg: Qube) -> Qube: ... - def __mod__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __mul__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __ne__(self, arg: object) -> Any: ... - def __neg__(self, *, recursive: bool = ...) -> Qube: ... - @staticmethod - def __new__(subtype: Any, *values: Any, **keywords: Any) -> Any: ... - def __or__(self, arg: Any) -> Any: ... - def __pos__(self, *, recursive: bool = ...) -> Qube: ... - def __pow__(self, arg: _Arraylike) -> Qube: ... - def __radd__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __rand__(self, arg: Any) -> Any: ... - def __repr__(self) -> str: ... - def __rfloordiv__(self, arg: _Arraylike) -> Qube: ... - def __rmod__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __rmul__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __ror__(self, arg: Any) -> Any: ... - def __rsub__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __rtruediv__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __rxor__(self, arg: Any) -> Any: ... - def __setitem__(self, indx: Any, arg: _Arraylike) -> None: ... - def __setstate__(self, state: dict[str, Qube]) -> None: ... - def __str__(self) -> str: ... - def __sub__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __truediv__(self, arg: _Arraylike, *, recursive: bool = ...) -> Qube: ... - def __xor__(self, arg: Any) -> Any: ... - def abs(self) -> Any: ... - def add_attr(self, name: str, value: Any = ...) -> Qube: ... - def all(self, axis: Any = ..., *, builtins: bool | None = ..., - masked: bool | None = ..., out: Any = ...) -> Any: ... - def all_true_or_masked(self, axis: Any = ..., *, - builtins: bool | None = ...) -> Any: ... - @staticmethod - def and_(*masks: _Arraylike) -> Any: ... - @property - def antimask(self) -> Any: ... - def any(self, axis: Any = ..., *, builtins: bool | None = ..., - masked: bool | None = ..., out: Any = ...) -> _Arraylike | bool: ... - def any_true_or_masked(self, axis: Any = ..., *, - builtins: bool | None = ...) -> Any: ... - def as_all_constant(self, constant: _Arraylike | None = ..., *, - recursive: Any = ...) -> Qube: ... - def as_all_masked(self, *, recursive: bool = ...) -> Qube: ... - def as_bool(self, *, copy: bool = ..., builtins: bool = ...) -> Qube: ... - def as_builtin(self, masked: Any = ...) -> Any: ... - @staticmethod - def as_diagonal(arg: Qube, axis: builtins.int, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - def as_float(self, *, recursive: bool = ..., copy: bool = ..., - builtins: bool = ...) -> Qube: ... - def as_int(self, *, copy: bool = ..., - builtins: bool = ...) -> Qube | builtins.int: ... - def as_mask_where_nonzero(self) -> Any: ... - def as_mask_where_nonzero_or_masked(self) -> Any: ... - def as_mask_where_zero(self) -> Any: ... - def as_mask_where_zero_or_masked(self) -> Any: ... - def as_numeric(self, *, recursive: bool = ...) -> Qube: ... - @staticmethod - def as_one_bool(value: Any) -> Any: ... - def as_one_masked(self, *, recursive: bool = ...) -> Qube: ... - def as_readonly(self, *, recursive: bool = ...) -> Qube: ... - def as_size_zero(self, axis: builtins.int = ..., *, recursive: Any = ...) -> Qube: ... - def as_this_type(self, arg: _Arraylike, *, recursive: bool = ..., coerce: bool = ..., - op: str = ...) -> Qube: ... - def broadcast(self, *objects: _Arraylike, recursive: bool = ..., - _protected: bool = ...) -> Any: ... - def broadcast_into_shape(self, shape: _ShapeOrTuple, *, recursive: bool = ..., - _protected: bool = ...) -> Any: ... - def broadcast_to(self, shape: _ShapeOrTuple, *, recursive: bool = ..., - _protected: bool = ...) -> Any: ... - def broadcasted_shape(self, *objects: _Arraylike, item: Any = ...) -> Any: ... - def cast(self, classes: type | tuple[type, ...] | list[type]) -> Qube: ... - def chain(self, arg: Qube) -> Qube: ... - def clip(self, lower: Any, upper: Any, *, remask: bool = ..., - inclusive: bool = ...) -> Qube: ... - def clone(self, *, recursive: bool = ..., - preserve: str | list[str] | tuple[str, ...] | None = ..., - retain_cache: bool = ...) -> Qube: ... - def collapse_mask(self, *, recursive: bool = ...) -> Qube: ... - def confirm_unit(self, unit: Unit | None) -> Qube: ... - def copy(self, *, recursive: bool = ..., readonly: bool = ...) -> Qube: ... - @property - def corners(self) -> _ShapeOrTuple: ... - def count_masked(self) -> Any: ... - def count_unmasked(self) -> Any: ... - @staticmethod - def cross(arg1: Qube, arg2: Qube, axis1: builtins.int = ..., - axis2: builtins.int = ..., *, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - @property - def default(self) -> Any: ... - def delete_deriv(self, key: str, *, override: bool = ...) -> Any: ... - def delete_derivs(self, *, override: bool = ..., - preserve: str | list[str] | tuple[str, ...] | None = ...) -> Any: ... - @property - def denom(self) -> _ShapeOrTuple: ... - @property - def derivs(self) -> dict[str, Qube]: ... - @staticmethod - def dot(arg1: Qube, arg2: Qube, axis1: builtins.int = ..., axis2: builtins.int = ..., - *, classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - @property - def drank(self) -> builtins.int: ... - @property - def dsize(self) -> builtins.int: ... - def dtype(self) -> Any: ... - def expand_mask(self, *, recursive: bool = ...) -> Qube: ... - def extract_denom(self, axis: builtins.int, index: builtins.int, - classes: type | tuple[type, ...] | list[type] = ...) -> Qube: ... - def extract_denoms(self) -> list[Any]: ... - def extract_numer(self, axis: builtins.int, index: builtins.int, - classes: type | tuple[type, ...] | list[type] = ..., *, - recursive: bool = ...) -> Qube: ... - @classmethod - def filled(cls, shape: _ShapeOrTuple, fill: _Arraylike = ..., *, - numer: _ShapeOrTuple | None = ..., denom: _ShapeOrTuple = ..., - mask: _Arraylike = ...) -> Qube: ... - def flatten(self, *, recursive: bool = ...) -> Qube: ... - def flatten_denom(self) -> Any: ... - def flatten_numer(self, classes: type | tuple[type, ...] | list[type] = ..., *, - recursive: bool = ...) -> Qube: ... - @classmethod - def from_scalars(cls, *scalars: _Arraylike, recursive: bool = ..., - readonly: bool = ..., classes: Any = ...) -> Qube: ... - def identity(self) -> Any: ... - def insert_deriv(self, key: str, deriv: Qube, *, override: bool = ...) -> Qube: ... - def insert_derivs(self, derivs: dict[str, Qube], *, override: bool = ...) -> Qube: ... - def into_unit(self, *, recursive: bool = ...) -> Any: ... - @staticmethod - def is_above(arg: Any, high: Any, inclusive: bool = ...) -> bool: ... - def is_all_masked(self) -> Any: ... - @staticmethod - def is_below(arg: Any, high: Any, inclusive: bool = ...) -> bool: ... - def is_bool(self) -> Any: ... - def is_float(self) -> Any: ... - @staticmethod - def is_inside(arg: Any, low: Any, high: Any, inclusive: bool = ...) -> bool: ... - def is_int(self) -> Any: ... - def is_numeric(self) -> Any: ... - @staticmethod - def is_one_false(value: Any) -> Any: ... - @staticmethod - def is_one_true(value: Any) -> Any: ... - @staticmethod - def is_outside(arg: Any, low: Any, high: Any, inclusive: bool = ...) -> bool: ... - def is_unitless(self) -> Any: ... - @property - def isize(self) -> builtins.int: ... - @property - def item(self) -> _ShapeOrTuple: ... - def join_items(self, classes: type | tuple[type, ...] | list[type]) -> Qube: ... - def len(self) -> Any: ... - def logical_not(self) -> Any: ... - @property - def mask(self) -> Any: ... - def mask_where(self, mask: _Arraylike, replace: Any = ..., *, remask: bool = ..., - recursive: bool = ...) -> Qube: ... - def mask_where_between(self, lower: _Arraylike, upper: _Arraylike, *, - mask_endpoints: Any = ..., replace: _Arraylike | None = ..., - remask: bool = ...) -> Qube: ... - def mask_where_eq(self, match: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_ge(self, limit: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_gt(self, limit: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_le(self, limit: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_lt(self, limit: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_ne(self, match: Any, replace: Any = ..., *, - remask: bool = ...) -> Qube: ... - def mask_where_outside(self, lower: _Arraylike, upper: _Arraylike, *, - mask_endpoints: Any = ..., replace: _Arraylike | None = ..., - remask: bool = ...) -> Qube: ... - def masked_single(self, *, recursive: Any = ...) -> Any: ... - def match_readonly(self, arg: Qube) -> Qube: ... - def mean(self, axis: Any = ..., *, recursive: bool = ..., - builtins: bool | None = ..., masked: bool | None = ..., dtype: Any = ..., - out: Any = ...) -> Any: ... - def move_axis(self, source: Any, destination: Any, *, recursive: bool = ..., - rank: builtins.int | None = ...) -> Qube: ... - @property - def mvals(self) -> Any: ... - def ndenumerate(self) -> Any: ... - @property - def ndim(self) -> Any: ... - @property - def ndims(self) -> Any: ... - @staticmethod - def norm(arg: Qube, axis: builtins.int = ..., *, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - @staticmethod - def norm_sq(arg: Any, axis: builtins.int = ..., *, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - @property - def nrank(self) -> builtins.int: ... - @property - def nsize(self) -> builtins.int: ... - @property - def numer(self) -> _ShapeOrTuple: ... - @classmethod - def ones(cls, shape: _ShapeOrTuple, dtype: str = ..., *, - numer: _ShapeOrTuple | None = ..., denom: _ShapeOrTuple = ..., - mask: _Arraylike = ...) -> Qube: ... - @staticmethod - def or_(*masks: _Arraylike) -> Any: ... - @staticmethod - def outer(arg1: Qube, arg2: Qube, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - def pickle_digits(self) -> str | float | builtins.int: ... - def pickle_reference(self) -> str | float | builtins.int: ... - @staticmethod - def prefer_builtins(status: bool | None = ...) -> bool: ... - @property - def rank(self) -> builtins.int: ... - @property - def readonly(self) -> bool: ... - def reciprocal(self, *, recursive: Any = ..., nozeros: Any = ...) -> Any: ... - def remask(self, mask: _Arraylike, *, recursive: bool = ..., - check: bool = ...) -> Qube: ... - def remask_or(self, mask: _Arraylike, *, recursive: bool = ..., - check: bool = ...) -> Qube: ... - def rename_deriv(self, key: str, new_key: str, *, method: str = ...) -> Qube: ... - def require_writable(self, force: bool = ...) -> Qube: ... - def require_writeable(self, force: bool = ...) -> Qube: ... - def reshape(self, shape: Any, *, recursive: bool = ...) -> Qube: ... - def reshape_denom(self, shape: _ShapeOrTuple) -> Qube: ... - def reshape_numer(self, shape: _ShapeOrTuple, - classes: type | tuple[type, ...] | list[type] = ..., - recursive: bool = ...) -> Qube: ... - def rms(self) -> _Arraylike: ... - def roll_axis(self, axis: builtins.int, start: builtins.int = ..., *, - recursive: bool = ..., rank: builtins.int | None = ...) -> Qube: ... - @staticmethod - def set_default_pickle_digits(digits: Any = ..., reference: Any = ...) -> Any: ... - def set_pickle_digits(self, digits: Any = ..., reference: Any = ...) -> Any: ... - def set_unit(self, unit: Unit | None, *, override: bool = ...) -> Any: ... - @property - def shape(self) -> _ShapeOrTuple: ... - def shrink(self, antimask: Any) -> Any: ... - @property - def size(self) -> builtins.int: ... - def slice_numer(self, axis: builtins.int, index1: builtins.int, index2: builtins.int, - classes: type | tuple[type, ...] | list[type] = ..., *, - recursive: bool = ...) -> Qube: ... - def split_items(self, nrank: builtins.int, - classes: type | tuple[type, ...] | list[type]) -> Qube: ... - @staticmethod - def stack(*args: Any, recursive: bool = ...) -> Qube: ... - def sum(self, axis: Any = ..., *, recursive: bool = ..., builtins: bool | None = ..., - masked: bool | None = ..., out: Any = ...) -> Any: ... - def swap_axes(self, axis1: builtins.int, axis2: builtins.int, *, - recursive: bool = ...) -> Qube: ... - def swap_items(self, classes: type | tuple[type, ...] | list[type]) -> Qube: ... - def transpose_denom(self, axis1: builtins.int = ..., - axis2: builtins.int = ...) -> Qube: ... - def transpose_numer(self, axis1: builtins.int = ..., axis2: builtins.int = ..., *, - recursive: bool = ...) -> Qube: ... - def tvl_all(self, axis: Any = ..., builtins: bool | None = ..., - masked: bool | None = ...) -> _Arraylike | bool: ... - def tvl_and(self, arg: _Arraylike, builtins: bool | None = ..., - masked: bool | None = ...) -> _Arraylike | bool: ... - def tvl_any(self, axis: Any = ..., builtins: bool | None = ..., - masked: bool | None = ...) -> _Arraylike | bool: ... - def tvl_eq(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_ge(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_gt(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_le(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_lt(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_ne(self, arg: _Arraylike, - builtins: bool | None = ...) -> _Arraylike | bool: ... - def tvl_or(self, arg: _Arraylike, builtins: bool | None = ..., - masked: bool | None = ...) -> _Arraylike | bool: ... - def unique_deriv_name(self, key: str, *objects: Qube) -> str: ... - @property - def unit_(self) -> Any: ... - @property - def units(self) -> Any: ... - def unshrink(self, antimask: _Arraylike, shape: _ShapeOrTuple = ...) -> Qube: ... - @property - def vals(self) -> Any: ... - @property - def values(self) -> Any: ... - def with_deriv(self, key: str, value: Qube, *, method: str = ...) -> Qube: ... - def without_deriv(self, key: str) -> Qube: ... - def without_derivs(self, *, - preserve: str | list[str] | tuple[str, ...] | None = ...) -> Qube: ... - def without_mask(self, *, recursive: bool = ...) -> Qube: ... - def without_unit(self, *, recursive: bool = ...) -> Qube: ... - @property - def wod(self) -> Any: ... - def zero(self) -> Any: ... - @classmethod - def zeros(cls, shape: _ShapeOrTuple, dtype: str = ..., *, - numer: _ShapeOrTuple | None = ..., denom: _ShapeOrTuple = ..., - mask: _Arraylike = ...) -> Qube: ... - -########################################################################################## diff --git a/src/polymath/scalar.py b/src/polymath/scalar.py index 46f340c..6057132 100755 --- a/src/polymath/scalar.py +++ b/src/polymath/scalar.py @@ -1,6 +1,14 @@ ########################################################################################## # polymath/scalar.py: Scalar subclass of PolyMath base class ########################################################################################## +"""The :class:`~polymath.Scalar` subclass, representing dimensionless numbers. + +A Scalar has an empty numerator shape, so each of its items is a single number. In +addition to the arithmetic that every :class:`~polymath.Qube` supports, this class +provides the trigonometric, exponential, and rounding functions, the statistical +reductions, quadratic solvers, and the conversions needed to use a Scalar as an array +index. +""" import functools import numpy as np @@ -40,8 +48,11 @@ class Scalar(Qube): def _minval(dtype): """The minimum value associated with this dtype. + Parameters: + dtype (numpy.dtype): The data type whose minimum value is required. + Returns: - float or int: The minimum value for the current data type. + float | int: The minimum value for the current data type. """ if dtype.kind == 'f': @@ -60,8 +71,11 @@ def _minval(dtype): def _maxval(dtype): """The maximum value associated with this dtype. + Parameters: + dtype (numpy.dtype): The data type whose maximum value is required. + Returns: - float or int: The maximum value for the current data type. + float | int: The maximum value for the current data type. """ if dtype.kind == 'f': @@ -80,7 +94,8 @@ def as_scalar(arg, *, recursive=True): """Convert the argument to Scalar if possible. Parameters: - arg: The object to convert to Scalar. + arg (ScalarLike | Unit): The object to convert to Scalar. A Unit becomes a + Scalar with the value one in that unit. recursive (bool, optional): True to include derivatives in the conversion. Returns: @@ -115,7 +130,7 @@ def to_scalar(self, indx, *, recursive=True): Scalar: This scalar object. Raises: - ValueError: If indx is not zero. + ValueError: If `indx` is not zero. """ if indx != 0: @@ -130,12 +145,12 @@ def as_index(self, *, masked=None): """Make this object suitable for indexing an N-dimensional NumPy array. Parameters: - masked: The value to insert in the place of a masked item. If None and the - object contains masked elements, the array will be flattened and masked - elements will be skipped. + masked (int | None, optional): The value to insert in the place of a masked + item. If None and the object contains masked elements, the array will be + flattened and masked elements will be skipped. Returns: - numpy.ndarray: An array suitable for indexing. + numpy.ndarray | int: An integer array or integer suitable for indexing. """ (index, _mask) = self.as_index_and_mask(purge=(masked is None), masked=masked) @@ -147,13 +162,14 @@ def as_index_and_mask(self, *, purge=False, masked=None): Parameters: purge (bool, optional): True to eliminate masked elements from the index; False to retain them but leave them masked. - masked: The index value to insert in place of any masked item. This may be - needed because each value in the returned index array must be an integer - and in range. If None (the default), then masked values in the index will - retain their unmasked values when the index is applied. + masked (int | None, optional): The index value to insert in place of any + masked item. This may be needed because each value in the returned index + array must be an integer and in range. If None (the default), then masked + values in the index will retain their unmasked values when the index is + applied. Returns: - tuple: A tuple containing (index_array, mask_array). + tuple[IntValsType, MaskType]: The integer index and the boolean mask. Raises: IndexError: If this object contains floating-point values. @@ -210,27 +226,33 @@ def int(self, top=None, *, remask=False, clip=False, inclusive=True, shift=None, Class Vector has a similar method :meth:`Vector.int`. Parameters: - top (int, optional): Optional nominal maximum integer value. + top (int | tuple[int] | None, optional): Optional nominal maximum integer + value. The default is for no upper limit. For compatibility with + :meth:`Vector.int`, `top` can also be a one-element tuple containing the + upper limit. remask (bool, optional): If True, values less than zero or greater than the specified top value (if provided) are masked. clip (bool, optional): If True, values less than zero or greater than the specified top value are clipped. inclusive (bool, optional): True to leave the top value unmasked; False to mask it. - shift (bool, optional): True to shift any occurrences of the top value down by - one; False to leave them unchanged. Default None lets shift match the - input value of inclusive. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int instead of an instance of Scalar. - Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. + shift (bool | None, optional): True to shift any occurrences of the top value + down by one; False to leave them unchanged. Default None lets shift match + the input value of inclusive. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int instead of an instance of + Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (int | None, optional): Value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - Scalar or int: The integer version of this scalar. + Scalar | int: The integer version of this Scalar. Raises: - ValueError: If this object has denominators. + ValueError: If this object has denominators or a unit. + ValueError: If `top` is multidimensional. """ if self._drank: @@ -240,6 +262,8 @@ def int(self, top=None, *, remask=False, clip=False, inclusive=True, shift=None, # For compatibility with Vector.int, where the first arg is the shape if isinstance(top, (list, tuple)): + if len(top) > 1: + raise ValueError('top input value has too many elements') top = top[0] if top is not None: @@ -265,7 +289,7 @@ def int(self, top=None, *, remask=False, clip=False, inclusive=True, shift=None, values = top - 1 if remask: - is_outside = Scalar.is_outside(self._values, 0, top, inclusive) + is_outside = Scalar.is_outside(self._values, 0, top, inclusive=inclusive) if clip: values = np.clip(values, 0, top-1) @@ -299,7 +323,7 @@ def frac(self, *, recursive=True): Parameters: recursive (bool, optional): True to include the derivatives in the returned - object, where frac() leaves their values unchanged; False to return an + object, where `frac()` leaves their values unchanged; False to return an object without derivatives. Returns: @@ -333,7 +357,7 @@ def sin(self, *, recursive=True): Parameters: recursive (bool, optional): True to include the derivatives of the sine inside - the returned object. Defaults to True. + the returned object. Returns: Scalar: The sine values. @@ -362,7 +386,7 @@ def cos(self, *, recursive=True): Parameters: recursive (bool, optional): True to include the derivatives of the cosine - inside the returned object. Defaults to True. + inside the returned object. Returns: Scalar: The cosine values. @@ -391,7 +415,7 @@ def tan(self, *, recursive=True): Parameters: recursive (bool, optional): True to include the derivatives of the tangent - inside the returned object. Defaults to True. + inside the returned object. Returns: Scalar: The tangent values. @@ -420,19 +444,19 @@ def arcsin(self, *, recursive=True, check=True): Parameters: recursive (bool, optional): True to include the derivatives of the arcsine - inside the returned object. Defaults to True. + inside the returned object. check (bool, optional): True to mask out the locations of any values outside the domain [-1,1]. If False, a ValueError will be raised if any value is - encountered where the arcsine is undefined. Check=True is slightly faster - if we already know at the time of the call that all input values are - valid. + encountered where the arcsine is undefined. ``check=False`` is slightly + faster if we already know at the time of the call that all input values + are valid. Returns: Scalar: The arcsine values. Raises: - ValueError: If this object has denominators. - ValueError: If check is False and any value is outside domain (-1,1). + ValueError: If this object has denominators or a unit. + ValueError: If `check` is False and any value is outside the domain [-1,1]. """ if self._drank: @@ -481,10 +505,10 @@ def arccos(self, *, recursive=True, check=True): Parameters: recursive (bool, optional): True to include the derivatives of the arccosine - inside the returned object. Defaults to True. + inside the returned object. check (bool, optional): True to mask out the locations of any values outside the domain [-1,1]. If False, a ValueError will be raised if any value is - encountered where the arccosine is undefined. Check=True is slightly + encountered where the arccosine is undefined. ``check=False`` is slightly faster if we already know at the time of the call that all input values are valid. @@ -492,8 +516,8 @@ def arccos(self, *, recursive=True, check=True): Scalar: The arccosine values. Raises: - ValueError: If this object has denominators. - ValueError: If check is False and any value is outside domain (-1,1). + ValueError: If this object has denominators or a unit. + ValueError: If `check` is False and any value is outside the domain [-1,1]. """ if self._drank: @@ -567,12 +591,12 @@ def arctan(self, *, recursive=True): return obj def arctan2(self, arg, *, recursive=True): - """The four-quadrant value of arctan2(y,x). + """The four-quadrant value of ``arctan2(y,x)``, where this object is **y**. If this object is read-only, the returned object will also be read-only. Parameters: - arg: The second argument to arctan2(). + arg (ScalarLike): The **x** argument to ``arctan2()``. recursive (bool, optional): True to include the derivatives of the arctangent inside the returned object. This is the result of merging the derivatives in both this object and the argument object. @@ -581,7 +605,7 @@ def arctan2(self, arg, *, recursive=True): Scalar: The four-quadrant arctangent values. Raises: - ValueError: If either object has denominators. + ValueError: If either object has denominators or the units are incompatible. """ y = self @@ -622,16 +646,16 @@ def sqrt(self, *, recursive=True, check=True): recursive (bool, optional): True to include the derivatives of the square root inside the returned object. check (bool, optional): True to mask out the locations of any values < 0 - before taking the square root. If False, a ValueError will be raised any - negative value encountered. Check=True is slightly faster if we already - know at the time of the call that all input values are valid. + before taking the square root. If False, a ValueError will be raised if + any negative value is encountered. ``check=False`` is slightly faster if + we already know at the time of the call that all input values are valid. Returns: Scalar: The square root values. Raises: ValueError: If this object has denominators. - ValueError: If check is False and any value is negative. + ValueError: If `check` is False and any value is negative. """ if self._drank: @@ -668,18 +692,18 @@ def log(self, *, recursive=True, check=True): Parameters: recursive (bool, optional): True to include the derivatives of the log inside - the returned object. Defaults to True. + the returned object. check (bool, optional): True to mask out the locations of any values <= 0 - before taking the log. If False, a ValueError will be raised any value <= - 0 is encountered. Check=True is slightly faster if we already know at the - time of the call that all input values are valid. Defaults to True. + before taking the log. If False, a ValueError will be raised if any value + <= 0 is encountered. ``check=False`` is slightly faster if we already know + at the time of the call that all input values are valid. Returns: Scalar: The natural logarithm values. Raises: ValueError: If this object has denominators. - ValueError: If check is False and any value is non-positive. + ValueError: If `check` is False and any value is non-positive. """ if self._drank: @@ -707,24 +731,24 @@ def log(self, *, recursive=True, check=True): return obj def exp(self, *, recursive=True, check=False): - """This Scalar raised to the given power or powers. + """The exponential ``e ** x`` of each value. If this object is read-only, the returned object will also be read-only. Parameters: - recursive (bool, optional): True to include the derivatives of the function + recursive (bool, optional): True to include the derivatives of the exponential inside the returned object. check (bool, optional): True to mask out the locations of any values that will - overflow to infinity. If False, a ValueError will be raised any value - overflows. Check=True is slightly faster if we already know at the time of - the call that all input values are valid. + overflow to infinity. If False, a ValueError will be raised if any value + overflows. ``check=False`` is slightly faster if we already know at the + time of the call that all input values are valid. Returns: Scalar: The exponential values. Raises: - ValueError: If this object has denominators. - ValueError: If check is False and any value overflows. + ValueError: If this object has denominators or a unit. + ValueError: If `check` is False and any value overflows. """ if self._drank: @@ -758,16 +782,18 @@ def sign(self, *, zeros=True, builtins=None, masked=None): """The sign of each value as +1, -1 or 0. Parameters: - zeros (bool, optional): If zeros is False, then only values of +1 and -1 are + zeros (bool, optional): If `zeros` is False, then only values of +1 and -1 are returned; sign(0) = +1 instead of 0. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int instead of an instance of Scalar. - Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int instead of an instance of + Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (int | None, optional): Value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - Scalar or int: The sign values. + Scalar | int: The sign values. """ result = Scalar(np.sign(self._values), mask=self._mask) @@ -788,23 +814,24 @@ def sign(self, *, zeros=True, builtins=None, masked=None): def solve_quadratic(a, b, c, *, recursive=True, include_antimask=False): """A tuple containing the two results of a quadratic equation as Scalars. - Duplicates and complex values are masked. The formula solved is: + Duplicates and complex values are masked. The formula solved is:: + a * x**2 + b * x + c = 0 The solution is implemented to provide maximal precision. Parameters: - a: The coefficient of x**2. - b: The coefficient of x. - c: The constant term. + a (ScalarLike): The coefficient of ``x**2``. + b (ScalarLike): The coefficient of ``x``. + c (ScalarLike): The constant term. recursive (bool, optional): True to include derivatives in the solution. - include_antimask (bool, optional): If True, a Boolean is also - returned containing True where the solution exists (because the - discriminant is nonnegative). + include_antimask (bool, optional): If True, a Boolean is also returned + containing True where the solution exists (because the discriminant is + nonnegative). Returns: - tuple: A tuple containing (x0, x1) or (x0, x1, antimask) if include_antimask - is True. + tuple[Scalar, Scalar] | tuple[Scalar, Scalar, Boolean]: The two solutions for + `x`, optionally followed by an antimask. """ a = Scalar.as_scalar(a, recursive=recursive) @@ -832,13 +859,14 @@ def solve_quadratic(a, b, c, *, recursive=True, include_antimask=False): def eval_quadratic(self, a, b, c, *, recursive=True): """Evaluate a quadratic function for this Scalar. - The value returned is: + The value returned is:: + a * self**2 + b * self + c Parameters: - a: The coefficient of x**2. - b: The coefficient of x. - c: The constant term. + a (ScalarLike): The coefficient of ``x**2``. + b (ScalarLike): The coefficient of ``x``. + c (ScalarLike): The constant term. recursive (bool, optional): True to include derivatives in the evaluation. Returns: @@ -857,19 +885,21 @@ def max(self, axis=None, *, builtins=None, masked=None, out=None): """The maximum of the unmasked values. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The maximum - is determined across these axes, leaving any remaining axes in the - returned value. If None (the default), then the maximum is performed - across all axes if the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int or float instead of an instance of - Scalar. Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. - out: Ignored. Enables "np.max(Scalar)" to work. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of + axes. The maximum is determined across these axes, leaving any remaining + axes in the returned value. If None (the default), then the maximum is + performed across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of an + instance of Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (float | int | None, optional): Value to return if `builtins` is True + but the returned value is masked. Default is to return a masked value + instead of a builtin type. + out (Any, optional): Ignored. Enables ``np.max(Scalar)`` to work. Returns: - Scalar or float or int: The maximum values. + Scalar | float | int: The maximum values. Raises: ValueError: If this object has denominators. @@ -927,19 +957,21 @@ def min(self, axis=None, *, builtins=None, masked=None, out=None): """The minimum of the unmasked values. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The minimum - is determined across these axes, leaving any remaining axes in the - returned value. If None (the default), then the minimum is performed - across all axes if the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int or float instead of an instance of - Scalar. Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. - out: Ignored. Enables "np.min(Scalar)" to work. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of + axes. The minimum is determined across these axes, leaving any remaining + axes in the returned value. If None (the default), then the minimum is + performed across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of an + instance of Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (float | int | None, optional): Value to return if `builtins` is True + but the returned value is masked. Default is to return a masked value + instead of a builtin type. + out (Any, optional): Ignored. Enables ``np.min(Scalar)`` to work. Returns: - Scalar or float or int: The minimum values. + Scalar | float | int: The minimum values. Raises: ValueError: If this object has denominators. @@ -1001,20 +1033,22 @@ def argmax(self, axis=None, *, builtins=None, masked=None): along that axis. The index is masked where the values along the axis are all masked. - If axis is None, then it returns the index of the maximum argument after + If `axis` is None, then it returns the index of the maximum argument after flattening the array. Parameters: - axis (int, optional): An optional integer axis. If None, it returns the index - of the maximum argument in the flattened array. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int instead of an instance of Scalar. - Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. + axis (int | None, optional): An optional integer axis. If None, it returns the + index of the maximum argument in the flattened array. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int instead of an instance of + Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (int | None, optional): Value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - Scalar or int: The index of the maximum value. + Scalar | int: The index of the maximum value. Raises: ValueError: If this object has denominators. @@ -1076,16 +1110,18 @@ def argmin(self, axis=None, *, builtins=None, masked=None): masked. Parameters: - axis (int, optional): An optional integer axis. If None, it returns the index - of the minimum argument in the flattened array. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int instead of an instance of Scalar. - Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. + axis (int | None, optional): An optional integer axis. If None, it returns the + index of the minimum argument in the flattened array. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int instead of an instance of + Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (int | None, optional): Value to return if `builtins` is True but the + returned value is masked. Default is to return a masked value instead of a + builtin type. Returns: - Scalar or int: The index of the minimum value. + Scalar | int: The index of the minimum value. Raises: ValueError: If this object has denominators. @@ -1141,10 +1177,19 @@ def argmin(self, axis=None, *, builtins=None, masked=None): @staticmethod def maximum(*args): - """A Scalar composed of the maximum among the given Scalars after they are all - broadcasted to the same shape. + """The element-by-element maximum among the given Scalars. Masked values are ignored in the comparisons. Derivatives are removed. + + Parameters: + *args (ScalarLike): Values to compare element by element. All `args` are + converted to Scalar and broadcasted to the same shape first. + + Returns: + Scalar: The element-by-element maximum values. + + Raises: + ValueError: If no arguments are given or any argument has denominators. """ if len(args) == 0: @@ -1214,8 +1259,19 @@ def maximum(*args): @staticmethod def minimum(*args): - """A Scalar composed of the minimum among the given Scalars after they - are all broadcasted to the same shape. + """The element-by-element minimum among the given Scalars. + + Masked values are ignored in the comparisons. Derivatives are removed. + + Parameters: + *args (ScalarLike): Values to compare element by element. All `args` are + converted to Scalar and broadcasted to the same shape first. + + Returns: + Scalar: The element-by-element minimum values. + + Raises: + ValueError: If no arguments are given or any argument has denominators. """ if len(args) == 0: @@ -1286,19 +1342,21 @@ def median(self, axis=None, *, builtins=None, masked=None, out=None): """The median of the unmasked values. Parameters: - axis (int or tuple, optional): An integer axis or a tuple of axes. The median - is determined across these axes, leaving any remaining axes in the - returned value. If None (the default), then the median is performed across - all axes of the object. - builtins (bool, optional): If True and the result is a single unmasked scalar, - the result is returned as a Python int or float instead of an instance of - Scalar. Default is the value specified by Qube.prefer_builtins(). - masked: Value to return if builtins is True but the returned value is masked. - Default is to return a masked value instead of a builtin type. - out: Ignored. Enables "np.median(Scalar)" to work. + axis (int | tuple[int, ...] | None, optional): An integer axis or a tuple of + axes. The median is determined across these axes, leaving any remaining + axes in the returned value. If None (the default), then the median is + performed across all axes of the object. + builtins (bool | None, optional): If True and the result is a single unmasked + scalar, the result is returned as a Python int or float instead of an + instance of Scalar. Default is the value specified by + :meth:`~polymath.Qube.prefer_builtins`. + masked (float | int | None, optional): Value to return if `builtins` is True + but the returned value is masked. Default is to return a masked value + instead of a builtin type. + out (Any, optional): Ignored. Enables ``np.median(Scalar)`` to work. Returns: - Scalar or float or int: The median values. + Scalar | float | int: The median values. Raises: ValueError: If this object has denominators. @@ -1396,7 +1454,7 @@ def sort(self, axis=0): Masked values appear at the end. Parameters: - axis (int): An integer axis to sort along. + axis (int, optional): An integer axis to sort along. Returns: Scalar: The sorted array. @@ -1460,7 +1518,7 @@ def reciprocal(self, *, recursive=True, nozeros=False): Raises: ValueError: If this object has denominators. - ValueError: If nozeros is True and a zero value is encountered. + ValueError: If `nozeros` is True and a zero value is encountered. """ if self._rank: @@ -1518,27 +1576,27 @@ def identity(self): ###################################################################################### def __le__(self, arg, *, builtins=True): - """self <= arg, element-by-element "less than or equal". + """``self <= arg``, element-by-element "less than or equal". This is an override of :meth:`Qube.__le__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this scalar is less than or equal to the argument. + Boolean | bool: True where this scalar is less than or equal to the argument. Raises: ValueError: If either object has denominators. + ValueError: If the shapes or units are incompatible. """ arg = Scalar.as_scalar(arg) + self._disallow_denom('<=') + arg._disallow_denom('<=') self._require_compatible_units(arg) - if self._denom or arg._denom: - self._disallow_denom('<=') - compare = (self._values <= arg._values) # Return a Python bool if possible @@ -1554,27 +1612,27 @@ def __le__(self, arg, *, builtins=True): return result def __lt__(self, arg, *, builtins=True): - """self < arg, element-by-element "less than". + """``self < arg``, element-by-element "less than". This is an override of :meth:`Qube.__lt__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this scalar is less than the argument. + Boolean | bool: True where this scalar is less than the argument. Raises: ValueError: If either object has denominators. + ValueError: If the shapes or units are incompatible. """ arg = Scalar.as_scalar(arg) + self._disallow_denom('<') + arg._disallow_denom('<') self._require_compatible_units(arg) - if self._denom or arg._denom: - self._disallow_denom('<') - compare = (self._values < arg._values) # Return a Python bool if possible @@ -1590,28 +1648,28 @@ def __lt__(self, arg, *, builtins=True): return result def __ge__(self, arg, *, builtins=True): - """self >= arg, element-by-element "less than or equal". + """``self >= arg``, element-by-element "greater than or equal". This is an override of :meth:`Qube.__ge__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this scalar is greater than or equal to the + Boolean | bool: True where this scalar is greater than or equal to the argument. Raises: ValueError: If either object has denominators. + ValueError: If the shapes or units are incompatible. """ arg = Scalar.as_scalar(arg) + self._disallow_denom('>=') + arg._disallow_denom('>=') self._require_compatible_units(arg) - if self._denom or arg._denom: - self._disallow_denom('>=') - compare = (self._values >= arg._values) # Return a Python bool if possible @@ -1627,27 +1685,27 @@ def __ge__(self, arg, *, builtins=True): return result def __gt__(self, arg, *, builtins=True): - """self > arg, element-by-element "greater than". + """``self > arg``, element-by-element "greater than". This is an override of :meth:`Qube.__gt__`. Parameters: - arg: The scalar to compare with. + arg (Any): The object to compare with. builtins (bool, optional): If True and the result is a single unmasked scalar, return a Python bool instead of a Boolean object. Returns: - Boolean or bool: True where this scalar is greater than the argument. + Boolean | bool: True where this scalar is greater than the argument. Raises: ValueError: If either object has denominators. + ValueError: If the shapes or units are incompatible. """ arg = Scalar.as_scalar(arg) + self._disallow_denom('>') + arg._disallow_denom('>') self._require_compatible_units(arg) - if self._denom or arg._denom: - self._disallow_denom('>') - compare = (self._values > arg._values) # Return a Python bool if possible @@ -1679,14 +1737,14 @@ def __round__(self, digits): ###################################################################################### def __abs__(self, *, recursive=True): - """abs(self), element-by-element absolute value. + """``abs(self)``, element-by-element absolute value. This is an override of :meth:`Qube.__abs__`. Parameters: - recursive (bool, optional): True to include the derivatives. For every - element that has its sign flipped, the sign will also be flipped in that - element's derivatives. + recursive (bool, optional): True to include the derivatives. For every element + that has its sign flipped, the sign will also be flipped in that element's + derivatives. Returns: Scalar: The absolute value. @@ -1865,6 +1923,24 @@ def _power_neg_half(self, *, recursive=True): # Generic exponentiation, PolyMath scalar to a single scalar power def __pow__(self, expo, *, recursive=True): + """``self ** expo``, element-by-element exponentiation. + + A result that is not real is masked, as is a division by zero arising from a + negative exponent. + + Parameters: + expo (ScalarLike): The exponent, which must be a shapeless or broadcastable + Scalar without a denominator. + recursive (bool, optional): True to include the derivatives of the result. + + Returns: + Scalar: The result of the exponentiation. + + Raises: + ValueError: If this object or `expo` has a denominator. + ValueError: If `expo` has a unit, or if this object has a unit and `expo` is + an array. + """ self._disallow_denom('**') diff --git a/src/polymath/scalar.pyi b/src/polymath/scalar.pyi deleted file mode 100644 index aaff309..0000000 --- a/src/polymath/scalar.pyi +++ /dev/null @@ -1,90 +0,0 @@ -########################################################################################## -# polymath/scalar.pyi -########################################################################################## -"""Type stub for :mod:`polymath.scalar`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any - -from numpy.typing import NDArray - -from polymath.qube import Qube, _Arraylike, _ShapeOrTuple - -__all__ = ['Scalar'] - -class Scalar(Qube): - HALFPI: Scalar - INF: Scalar - MASKED: Scalar - NEGINF: Scalar - ONE: Scalar - PI: Scalar - THREE: Scalar - TWO: Scalar - TWOPI: Scalar - ZERO: Scalar - def __abs__(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __ge__(self, arg: Any, *, - builtins: bool = ...) -> _Arraylike | bool: ... - def __gt__(self, arg: Any, *, - builtins: bool = ...) -> _Arraylike | bool: ... - def __le__(self, arg: Any, *, - builtins: bool = ...) -> _Arraylike | bool: ... - def __lt__(self, arg: Any, *, - builtins: bool = ...) -> _Arraylike | bool: ... - def __pow__(self, expo: Any, *, recursive: Any = ...) -> Any: ... - def __round__(self, digits: builtins.int) -> _Arraylike: ... - def abs(self, *, recursive: bool = ...) -> _Arraylike: ... - def arccos(self, *, recursive: bool = ..., check: bool = ...) -> _Arraylike: ... - def arcsin(self, *, recursive: bool = ..., check: bool = ...) -> _Arraylike: ... - def arctan(self, *, recursive: bool = ...) -> _Arraylike: ... - def arctan2(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def argmax(self, axis: builtins.int | None = ..., *, builtins: bool | None = ..., - masked: Any = ...) -> _Arraylike | builtins.int: ... - def argmin(self, axis: builtins.int | None = ..., *, builtins: bool | None = ..., - masked: Any = ...) -> _Arraylike | builtins.int: ... - def as_index(self, *, masked: Any = ...) -> NDArray[Any]: ... - def as_index_and_mask(self, *, purge: bool = ..., - masked: Any = ...) -> _ShapeOrTuple: ... - @staticmethod - def as_scalar(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def cos(self, *, recursive: bool = ...) -> _Arraylike: ... - def eval_quadratic(self, a: Any, b: Any, c: Any, *, - recursive: bool = ...) -> _Arraylike: ... - def exp(self, *, recursive: bool = ..., check: bool = ...) -> _Arraylike: ... - def frac(self, *, recursive: bool = ...) -> _Arraylike: ... - def identity(self) -> _Arraylike: ... - def int(self, top: builtins.int | None = ..., *, remask: bool = ..., - clip: bool = ..., inclusive: bool = ..., shift: bool | None = ..., - builtins: bool | None = ..., masked: Any = ...) -> _Arraylike | builtins.int: ... - def log(self, *, recursive: bool = ..., check: bool = ...) -> _Arraylike: ... - def max(self, axis: Any = ..., *, builtins: bool | None = ..., masked: Any = ..., - out: Any = ...) -> _Arraylike | float | builtins.int: ... - @staticmethod - def maximum(*args: Any) -> Any: ... - def median(self, axis: Any = ..., *, builtins: bool | None = ..., masked: Any = ..., - out: Any = ...) -> _Arraylike | float | builtins.int: ... - def min(self, axis: Any = ..., *, builtins: bool | None = ..., masked: Any = ..., - out: Any = ...) -> _Arraylike | float | builtins.int: ... - @staticmethod - def minimum(*args: Any) -> Any: ... - def reciprocal(self, *, recursive: bool = ..., nozeros: bool = ...) -> _Arraylike: ... - def sign(self, *, zeros: bool = ..., builtins: bool | None = ..., - masked: Any = ...) -> _Arraylike | builtins.int: ... - def sin(self, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def solve_quadratic(a: Any, b: Any, c: Any, *, recursive: bool = ..., - include_antimask: bool = ...) -> _ShapeOrTuple: ... - def sort(self, axis: builtins.int = ...) -> _Arraylike: ... - def sqrt(self, *, recursive: bool = ..., check: bool = ...) -> _Arraylike: ... - def tan(self, *, recursive: bool = ...) -> _Arraylike: ... - def to_scalar(self, indx: builtins.int, *, recursive: bool = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/typedefs.py b/src/polymath/typedefs.py new file mode 100644 index 0000000..c133fbb --- /dev/null +++ b/src/polymath/typedefs.py @@ -0,0 +1,128 @@ +########################################################################################## +# polymath/typedefs.py +########################################################################################## +"""Public type aliases naming the values that the PolyMath constructors accept. + +These aliases exist for documentation and for downstream code that uses type annotations. +Each alias is an ordinary runtime object, so it can be imported and used in an annotation +anywhere:: + + from polymath import Scalar, Vector3 + from polymath.typedefs import Vector3Like + + def speed(velocity: Vector3Like) -> Scalar: + return Vector3.as_vector3(velocity).norm() + +Each alias names what the corresponding class converts, which is broader than the class +itself: any :class:`~polymath.Qube` subclass qualifies, because the constructors re-wrap +any of them. Annotating with an alias therefore documents intent and rules out unrelated +types such as dictionaries and strings, but it does not restrict an argument to objects of +one PolyMath class alone. +""" + +from collections.abc import Iterator +from typing import Any, Literal, Protocol, TypeAlias + +import numpy as np + +from polymath.qube import Qube + +__all__ = ['BooleanLike', 'IntValsType', 'MaskType', 'Matrix3Like', 'MatrixLike', + 'PairLike', 'QuaternionLike', 'QubeLike', 'ScalarLike', 'ValsType', + 'Vector3Like', 'VectorLike'] + +# Float arrays with the specified lower limit on dimensions and/or trailing axes +_Array : TypeAlias = np.ndarray[tuple[int, ...], + np.dtype[np.number[Any] | np.bool_]] +_Array1D: TypeAlias = np.ndarray[tuple[int, *tuple[int, ...]], + np.dtype[np.number[Any] | np.bool_]] +_Array2D: TypeAlias = np.ndarray[tuple[int, int, *tuple[int, ...]], + np.dtype[np.number[Any] | np.bool_]] +_Array2 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[2]], + np.dtype[np.number[Any] | np.bool_]] +_Array3 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[3]], + np.dtype[np.number[Any] | np.bool_]] +_Array4 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[4]], + np.dtype[np.number[Any] | np.bool_]] +_Array33: TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[3], Literal[3]], + np.dtype[np.number[Any] | np.bool_]] + +# Typed arrays +_BoolArray: TypeAlias = np.ndarray[tuple[int, ...], np.dtype[np.bool_]] +_IntArray : TypeAlias = np.ndarray[tuple[int, ...], np.dtype[np.integer[Any]]] + +# Numeric ArrayLike type, described by a protocol rather than by list and tuple because +# both are invariant in their member type: a list[float] does not match a list whose +# member type is the union below. Matching a protocol is structural instead, so a sequence +# qualifies at any depth of nesting, however deep. A str does not qualify, because it +# defines no __reversed__ and its __getitem__ returns another str. A member may be an +# array or a PolyMath object as well as a number, because np.asarray() stacks a sequence +# of them into one array of higher rank. +_Scalar: TypeAlias = float | int | bool | np.bool_ + + +class _NestedSequence(Protocol): + """A sequence of numbers, arrays or PolyMath objects, nested to any depth.""" + + def __len__(self, /) -> int: ... + def __getitem__(self, index: int, /) -> '_SeqMember': ... + def __contains__(self, x: object, /) -> bool: ... + def __iter__(self, /) -> 'Iterator[_SeqMember]': ... + def __reversed__(self, /) -> 'Iterator[_SeqMember]': ... + def count(self, value: Any, /) -> int: ... + def index(self, value: Any, /) -> int: ... + + +# Named after the class so that it can name the class in turn. +_SeqMember: TypeAlias = Qube | _Scalar | _Array | _NestedSequence +_ArrayLike: TypeAlias = _NestedSequence + +BooleanLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Boolean`: a PolyMath object, a numeric +array, a nested list or tuple of numbers, or a single number.""" + +ScalarLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Scalar`: a PolyMath object, a numeric +array, a nested list or tuple of numbers, or a single number.""" + +PairLike: TypeAlias = Qube | _Array2 | _ArrayLike | float | int +"""Any value convertible to a :class:`~polymath.Pair`: a PolyMath object, a numeric array +whose last axis has length two, or a nested list or tuple of numbers. As a special case, +a single value becomes a Pair with the value repeated.""" + +VectorLike: TypeAlias = Qube | _Array1D | _ArrayLike +"""Any value convertible to a :class:`~polymath.Vector`: a PolyMath object, a numeric +array of one or more axes, or a nested list or tuple of numbers.""" + +Vector3Like: TypeAlias = Qube | _Array3 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Vector3`: a PolyMath object, a numeric +array whose last axis has length three, or a nested list or tuple of numbers.""" + +MatrixLike: TypeAlias = Qube | _Array2D | _ArrayLike +"""Any value convertible to a :class:`~polymath.Matrix`: a PolyMath object, a numeric +array of two or more axes, or a nested list or tuple of numbers.""" + +Matrix3Like: TypeAlias = Qube | _Array33 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Matrix3`: a PolyMath object, a numeric +array whose last two axes each have length three, or a nested list or tuple of +numbers.""" + +QuaternionLike: TypeAlias = Qube | _Array4 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Quaternion`: a PolyMath object, a numeric +array whose last axis has length four, or a nested list or tuple of numbers.""" + +QubeLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Qube` subclass: a PolyMath object, a +numeric array, a nested list or tuple of numbers, or a single number.""" + +ValsType: TypeAlias = _Scalar | _Array +"""Any value that might occupy the `.vals` attribute of a Qube.""" + +MaskType: TypeAlias = bool | np.bool_ | _BoolArray +"""Any value that might occupy the `.mask` attribute of a Qube.""" + +IntValsType: TypeAlias = int | _IntArray +"""Any value that might occupy the `.vals` attribute of a Qube and must also be integral. +""" + +########################################################################################## diff --git a/src/polymath/typedefs.pyi b/src/polymath/typedefs.pyi new file mode 100644 index 0000000..1f304ae --- /dev/null +++ b/src/polymath/typedefs.pyi @@ -0,0 +1,127 @@ +########################################################################################## +# polymath/typedefs.pyi +########################################################################################## +"""Type stub for :mod:`polymath.typedefs`, mirroring the aliases defined there. + +The `src` tree carries no inline annotations, so these aliases exist for the benefit of +downstream code that does annotate. Each alias is an ordinary runtime object, so it can be +imported and used in an annotation anywhere:: + + from polymath import Scalar, Vector3 + from polymath.typedefs import Vector3Like + + def speed(velocity: Vector3Like) -> Scalar: + return Vector3.as_vector3(velocity).norm() + +Each alias names what the corresponding class converts, which is broader than the class +itself: any :class:`~polymath.Qube` subclass qualifies, because the constructors re-wrap +any of them. Annotating with an alias therefore documents intent and rules out unrelated +types such as dictionaries and strings, but it does not restrict an argument to objects of +one PolyMath class alone. +""" + +from collections.abc import Iterator +from typing import Any, Literal, Protocol, TypeAlias + +import numpy as np + +from polymath import Qube + +__all__ = ['BooleanLike', 'IntValsType', 'MaskType', 'Matrix3Like', 'MatrixLike', + 'PairLike', 'QuaternionLike', 'QubeLike', 'ScalarLike', 'ValsType', + 'Vector3Like', 'VectorLike'] + +# Float arrays with the specified lower limit on dimensions and/or trailing axes +_Array : TypeAlias = np.ndarray[tuple[int, ...], + np.dtype[np.number[Any] | np.bool_]] +_Array1D: TypeAlias = np.ndarray[tuple[int, *tuple[int, ...]], + np.dtype[np.number[Any] | np.bool_]] +_Array2D: TypeAlias = np.ndarray[tuple[int, int, *tuple[int, ...]], + np.dtype[np.number[Any] | np.bool_]] +_Array2 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[2]], + np.dtype[np.number[Any] | np.bool_]] +_Array3 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[3]], + np.dtype[np.number[Any] | np.bool_]] +_Array4 : TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[4]], + np.dtype[np.number[Any] | np.bool_]] +_Array33: TypeAlias = np.ndarray[tuple[*tuple[int, ...], Literal[3], Literal[3]], + np.dtype[np.number[Any] | np.bool_]] + +# Typed arrays +_BoolArray: TypeAlias = np.ndarray[tuple[int, ...], np.dtype[np.bool_]] +_IntArray : TypeAlias = np.ndarray[tuple[int, ...], np.dtype[np.integer[Any]]] + +# Numeric ArrayLike type, described by a protocol rather than by list and tuple because +# both are invariant in their member type: a list[float] does not match a list whose +# member type is the union below. Matching a protocol is structural instead, so a sequence +# qualifies at any depth of nesting, however deep. A str does not qualify, because it +# defines no __reversed__ and its __getitem__ returns another str. A member may be an +# array or a PolyMath object as well as a number, because np.asarray() stacks a sequence +# of them into one array of higher rank. +_Scalar: TypeAlias = float | int | bool | np.bool_ + + +class _NestedSequence(Protocol): + """A sequence of numbers, arrays or PolyMath objects, nested to any depth.""" + + def __len__(self, /) -> int: ... + def __getitem__(self, index: int, /) -> _SeqMember: ... + def __contains__(self, x: object, /) -> bool: ... + def __iter__(self, /) -> Iterator[_SeqMember]: ... + def __reversed__(self, /) -> Iterator[_SeqMember]: ... + def count(self, value: Any, /) -> int: ... + def index(self, value: Any, /) -> int: ... + + +# Named after the class so that it can name the class in turn. +_SeqMember: TypeAlias = Qube | _Scalar | _Array | _NestedSequence +_ArrayLike: TypeAlias = _NestedSequence + +BooleanLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Boolean`: a PolyMath object, a numeric +array, a nested sequence of numbers, or a single number.""" + +ScalarLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Scalar`: a PolyMath object, a numeric +array, a nested sequence of numbers, or a single number.""" + +PairLike: TypeAlias = Qube | _Array2 | _ArrayLike | float | int +"""Any value convertible to a :class:`~polymath.Pair`: a PolyMath object, a numeric array +whose last axis has length two, or a nested sequence of numbers. As a special case, a +single value becomes a Pair with the value repeated.""" + +VectorLike: TypeAlias = Qube | _Array1D | _ArrayLike +"""Any value convertible to a :class:`~polymath.Vector`: a PolyMath object, a numeric +array of one or more axes, or a nested sequence of numbers.""" + +Vector3Like: TypeAlias = Qube | _Array3 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Vector3`: a PolyMath object, a numeric +array whose last axis has length three, or a nested sequence of numbers.""" + +MatrixLike: TypeAlias = Qube | _Array2D | _ArrayLike +"""Any value convertible to a :class:`~polymath.Matrix`: a PolyMath object, a numeric +array of two or more axes, or a nested sequence of numbers.""" + +Matrix3Like: TypeAlias = Qube | _Array33 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Matrix3`: a PolyMath object, a numeric +array whose last two axes each have length three, or a nested sequence of numbers.""" + +QuaternionLike: TypeAlias = Qube | _Array4 | _ArrayLike +"""Any value convertible to a :class:`~polymath.Quaternion`: a PolyMath object, a numeric +array whose last axis has length four, or a nested sequence of numbers.""" + +QubeLike: TypeAlias = Qube | _Array | _ArrayLike | _Scalar +"""Any value convertible to a :class:`~polymath.Qube` subclass: a PolyMath object, a +numeric array, a nested sequence of numbers, or a single number.""" + +ValsType: TypeAlias = _Scalar | _Array +"""Any value that might occupy the `.vals` attribute if a Qube.""" + +MaskType: TypeAlias = bool | np.bool_ | _BoolArray +"""Any value that might occupy the `.mask` attribute if a Qube.""" + +IntValsType: TypeAlias = int | _IntArray +"""Any value that might occupy the `.vals` attribute if a Qube and must also be integral. +""" + +########################################################################################## diff --git a/src/polymath/unit.py b/src/polymath/unit.py index 6a777af..6a3be7d 100755 --- a/src/polymath/unit.py +++ b/src/polymath/unit.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/unit.py ########################################################################################## +"""The :class:`~polymath.Unit` class, which gives a PolyMath object physical units. + +A Unit records the exponents of distance, time, and angle, together with the factor that +converts a value into the standard units of kilometers, seconds, and radians. The values +inside a PolyMath object are always held in those standard units; a Unit affects only how +numbers are interpreted on input and presented on output. +""" from collections import defaultdict import functools @@ -22,12 +29,12 @@ class Unit: Attributes: exponents (tuple[int, int, int]): The exponents on dimensions of length, time, and angle, respectively. - triple (tuple[int, int, int]): Three integers representing the exact factor that - to multiply a value in this unit by to a value in standard units involving + triple (tuple[int, int, int]): Three integers representing the exact factor by + which to multiply a value in this unit to obtain a value in standard units (km, seconds, and radians). This factor is represented by three numbers, (**numer**, **denom**, and **expo**), where the exact factor equals (`numer/denom * pi**expo`). - name (str, dict or None): An optional name for this unit. Alternatively, a name + name (str | dict | None): An optional name for this unit. Alternatively, a name can be defined by a dictionary keyed by unit names, returning exponents. For example, the name "km/s" can be given by `{"km":1, "s":-1}`. @@ -58,8 +65,8 @@ def __init__(self, exponents, triple, name=None): * [2] The exponent on pi that should multiply the numerator of this factor. - name (str or dict, optional): The name of the unit. It is represented by a - string or by a dictionary of unit exponents keyed by the unit names. + name (str | dict | None, optional): The name of the unit. It is represented by + a string or by a dictionary of unit exponents keyed by the unit names. Notes: For example, a unit of degrees would have a triple (1,180,1). This defines a @@ -95,11 +102,13 @@ def __init__(self, exponents, triple, name=None): self.name = name @property - def from_unit_factor(self): + def from_unit_factor(self) -> float: + """The factor that converts a value from this unit to default units.""" return self.factor @property - def into_unit_factor(self): + def into_unit_factor(self) -> float: + """The factor that converts a value from default units into this unit.""" return self.factor_inv @staticmethod @@ -107,14 +116,15 @@ def as_unit(arg): """Convert the given argument to a Unit object. Parameters: - arg: The argument to convert. Can be an object of class Unit, one of the - standard unit names, or None. + arg (Unit | str | None): The argument to convert. Can be an object of class + Unit, one of the standard unit names, or None. Returns: - Unit or None: The converted Unit object, or None if arg is None. + Unit | None: The converted Unit object, or None if `arg` is None. Raises: - ValueError: If the argument is not a recognized unit. + KeyError: If `arg` is a string that is not a recognized unit name. + TypeError: If `arg` is not a type that can be converted to Unit. """ if arg is None: @@ -124,15 +134,15 @@ def as_unit(arg): elif isinstance(arg, Unit): return arg else: - raise ValueError('not a recognized unit: ' + str(arg)) + raise TypeError(f'not a recognized unit: {arg!r}') @staticmethod def can_match(first, second): - """Check if the unit can match. + """Check if two units can match. Parameters: - first (Unit or None): The first unit object. - second (Unit or None): The second unit object. + first (Unit | None): The first unit object. + second (Unit | None): The second unit object. Returns: bool: True if the units can match, meaning that either they have the same @@ -149,8 +159,8 @@ def require_compatible(first, second, info=''): """Raise a ValueError if the arguments are not compatible units. Parameters: - first (Unit or None): The first unit object. - second (Unit or None): The second unit object. + first (Unit | None): The first unit object. + second (Unit | None): The second unit object. info (str, optional): Info to embed into the error message. Raises: @@ -166,8 +176,8 @@ def do_match(first, second): """Check if the units match. Parameters: - first (Unit or None): The first unit object. - second (Unit or None): The second unit object. + first (Unit | None): The first unit object. + second (Unit | None): The second unit object. Returns: bool: True if the units match, meaning that they have the same exponents. @@ -186,12 +196,12 @@ def require_match(first, second, info=''): """Raise a ValueError if the units are not the same. Parameters: - first (Unit or None): The first unit object. - second (Unit or None): The second unit object. + first (Unit | None): The first unit object. + second (Unit | None): The second unit object. info (str, optional): Info to embed into the error message. Raises: - ValueError: If the units are not compatible. + ValueError: If the units do not match. """ if not Unit.do_match(first, second): @@ -203,7 +213,7 @@ def is_angle(arg): """Check if the argument could be used as an angle. Parameters: - arg (Unit or None): The unit object to check. + arg (Unit | None): The unit object to check. Returns: bool: True if the argument could be used as an angle. @@ -218,7 +228,7 @@ def require_angle(arg, info=''): """Raise a ValueError if the argument could not be used as an angle. Parameters: - arg (Unit or None): The unit object to check. + arg (Unit | None): The unit object to check. info (str, optional): Info to embed into the error message. Raises: @@ -231,10 +241,10 @@ def require_angle(arg, info=''): @staticmethod def is_unitless(arg): - """True if the argument is unitless. + """Check if the argument is unitless. Parameters: - arg (Unit or None): The unit object to check. + arg (Unit | None): The unit object to check. Returns: bool: True if the argument is unitless. @@ -249,7 +259,7 @@ def require_unitless(arg, info=''): """Raise a ValueError if the argument is not unitless. Parameters: - arg (Unit or None): The unit object to check. + arg (Unit | None): The unit object to check. info (str, optional): Info to embed into the error message. Raises: @@ -263,43 +273,41 @@ def require_unitless(arg, info=''): raise ValueError(f'{info_}unit is not permitted: {arg}') def from_this(self, value): - """Convert a scalar or numpy array in this unit to a standard unit. + """Convert values in this unit to a standard unit. Parameters: - value (scalar or numpy.ndarray): The value to convert from this unit to - standard units of km, seconds and radians. + value (Any): The value to convert from this unit to standard units of km, + seconds and radians. Returns: - scalar or numpy.ndarray: The value converted to a standard unit. + Any: The `value` converted to a standard unit. """ return self.factor * value def into_this(self, value): - """Convert a scalar or numpy array from a standard unit to this unit. + """Convert values from a standard unit to this unit. Parameters: - value (scalar or numpy.ndarray): The value to convert from a standard unit - to this unit. + value (Any): The value to convert from a standard unit to this unit. Returns: - scalar or numpy.ndarray: The converted value in this unit. + Any: The converted `value` in this unit. """ return self.factor_inv * value @staticmethod def from_unit(unit, value): - """Convert a scalar or numpy array in the given unit to a standard unit. + """Convert values in the given unit to a standard unit. Parameters: - unit (Unit or None): The unit to convert from. - value (scalar or numpy.ndarray): The value to convert. + unit (Unit | None): The unit to convert from. + value (Any): The value to convert. Returns: - scalar or numpy.ndarray: The `value` converted from the given `unit` to - standard units involving km, seconds and radians. If `unit` is None, - `value` is returned untouched. + Any: The `value` converted from the given `unit` to standard units involving + km, seconds and radians. If `unit` is None, `value` is returned untouched. """ if unit is None: @@ -309,16 +317,15 @@ def from_unit(unit, value): @staticmethod def into_unit(unit, value): - """Convert a scalar or numpy array from a standard unit to given unit. + """Convert values from a standard unit to given unit. Parameters: - unit (Unit or None): The unit to convert to. - value (scalar or numpy.ndarray): The value to convert. + unit (Unit | None): The unit to convert to. + value (Any): The value to convert. Returns: - scalar or numpy.ndarray: The `value` in standard units involving km, seconds - and radians converted to the given `unit`. If `unit` is None, `value` is - returned untouched. + Any: The `value` in standard units involving km, seconds and radians converted + to the given `unit`. If `unit` is None, `value` is returned untouched. """ if unit is None: @@ -327,18 +334,18 @@ def into_unit(unit, value): return unit.factor_inv * value def convert(self, value, unit, info=''): - """Convert the unit of a scalar or NumPy array. + """Convert the unit of one or more values. The value is assumed to be in this unit, and it is returned in the new unit specified. Conversions are exact whenever possible. Parameters: - value (scalar or numpy.ndarray): The value to convert. - unit (Unit or None): The target unit. If None, converts to unitless. + value (Any): The value to convert. + unit (Unit | None): The target unit. If None, converts to unitless. info (str, optional): Info to embed into the error message. Returns: - scalar or numpy.ndarray: The converted value in the target unit. + Any: The converted value in the target unit. Raises: ValueError: If the units are incompatible for conversion. @@ -365,15 +372,15 @@ def convert(self, value, unit, info=''): ###################################################################################### def __mul__(self, arg): - """Multiply this Unit object by another Unit object or scalar. + """Multiply this Unit object by a Unit object or a scale factor. Parameters: - arg (Unit, None, or numbers.Real): The object to multiply by. + arg (Unit | float | int | None): The object to multiply this Unit by, another + Unit or a scale factor. None is treated as unitless. Returns: - Unit: The product of the unit multiplication. If the type of `arg` is not - supported, NotImplemented is returned instead, so that Python falls back on - the reflected operation of `arg`. + Unit | NotImplemented: The product of the unit multiplication if possible; + otherwise, the NotImplemented sentinel. Raises: TypeError: If neither operand supports the multiplication. @@ -397,24 +404,32 @@ def __mul__(self, arg): return NotImplemented def __rmul__(self, arg): - return self.__mul__(arg) + """Right-multiply this Unit object by a Unit object or a scale factor. - def __div__(self, arg): - return self.__truediv__(arg) + Parameters: + arg (Unit | float | int | None): The object to multiply this Unit by, another + Unit or a scale factor. None is treated as unitless. - def __rdiv__(self, arg): - return self.__rtruediv__(arg) + Returns: + Unit | NotImplemented: The product of the unit multiplication if possible; + otherwise, the NotImplemented sentinel. + + Raises: + TypeError: If neither operand supports the multiplication. + """ + + return self.__mul__(arg) def __truediv__(self, arg): - """Divide this Unit object by another Unit object or scalar. + """Divide this Unit object by another Unit or a scale factor. Parameters: - arg (Unit, None, or numbers.Real): The object to divide by. + arg (Unit | float | int | None): The object to divide by, another Unit or a + scale factor. None is treated as unitless. Returns: - Unit: The quotient of the unit division. If the type of `arg` is not - supported, NotImplemented is returned instead, so that Python falls back on - the reflected operation of `arg`. + Unit | NotImplemented: The quotient of the unit division if possible; + otherwise, the NotImplemented sentinel. Raises: TypeError: If neither operand supports the division. @@ -437,16 +452,32 @@ def __truediv__(self, arg): return NotImplemented + def __div__(self, arg): + """Divide this Unit object by another Unit or a scale factor. + + Parameters: + arg (Unit | float | int | None): The object to divide by, another Unit or a + scale factor. None is treated as unitless. + + Returns: + Unit | NotImplemented: The quotient of the unit division if possible; + otherwise, the NotImplemented sentinel. + + Raises: + TypeError: If neither operand supports the division. + """ + + return self.__truediv__(arg) + def __rtruediv__(self, arg): - """Divide a scalar by this Unit object. + """Divide a scalar by this Unit. Parameters: - arg (None or numbers.Real): The scalar to divide. + arg (float | int | None): The scalar to divide. Returns: - Unit: The reciprocal of this Unit object multiplied by arg. If the type of - `arg` is not supported, NotImplemented is returned instead, so that Python - falls back on the reflected operation of `arg`. + Unit | NotImplemented: The reciprocal of this Unit object multiplied by `arg` + if possible; otherwise, the NotImplemented sentinel. Raises: TypeError: If neither operand supports the division. @@ -460,17 +491,34 @@ def __rtruediv__(self, arg): return NotImplemented + def __rdiv__(self, arg): + """Divide a scalar by this Unit. + + Parameters: + arg (float | int | None): The scalar to divide. + + Returns: + Unit | NotImplemented: The reciprocal of this Unit object multiplied by `arg` + if possible; otherwise, the NotImplemented sentinel. + + Raises: + TypeError: If neither operand supports the division. + """ + + return self.__rtruediv__(arg) + def __pow__(self, power): """Raise this Unit object to the specified power. Parameters: - power (int or float): The exponent. Must be an integer or half-integer. + power (int | float): The exponent. Must be an integer or half-integer. Returns: Unit: This Unit object raised to the specified power. Raises: - ValueError: If the power is not an integer or half-integer. + ValueError: If the power is not an integer or half-integer, or if the Unit + cannot be raised to a half-integer power. """ ipower = int(power) @@ -504,7 +552,7 @@ def sqrt(self): """The square root of this Unit object. Returns: - Unit: The square root of this Unit object. + Unit: The square root of this Unit. Raises: ValueError: If the exponents are not even numbers. @@ -540,11 +588,11 @@ def mul_units(arg1, arg2): """Multiply two Unit objects. Parameters: - arg1 (Unit or None): The first Unit object. - arg2 (Unit or None): The second Unit object. + arg1 (Unit | None): The first Unit object. + arg2 (Unit | None): The second Unit object. Returns: - Unit or None: The product of the two Unit objects, or None if both arguments + Unit | None: The product of the two Unit objects, or None if both arguments are None. """ @@ -560,11 +608,11 @@ def div_units(arg1, arg2): """Divide two Unit objects. Parameters: - arg1 (Unit or None): The numerator Unit object. - arg2 (Unit or None): The denominator Unit object. + arg1 (Unit | None): The numerator Unit object. + arg2 (Unit | None): The denominator Unit object. Returns: - Unit or None: The quotient of the two Unit objects, or None if both arguments + Unit | None: The quotient of the two Unit objects, or None if both arguments are None. """ @@ -581,10 +629,10 @@ def sqrt_unit(unit): """The square root of a Unit object. Parameters: - unit (Unit or None): The Unit object to take the square root of. + unit (Unit | None): The Unit object to take the square root of. Returns: - Unit or None: The square root of the Unit object, or None if unit is None. + Unit | None: The square root of the Unit object, or None if `unit` is None. Raises: ValueError: If the exponents are not even numbers. @@ -600,11 +648,11 @@ def unit_power(unit, power): """Raise a Unit object to the specified power. Parameters: - unit (Unit or None): The Unit object to raise to a power. - power (int or float): The exponent. Must be an integer or half-integer. + unit (Unit | None): The Unit object to raise to a power. + power (int | float): The exponent. Must be an integer or half-integer. Returns: - Unit or None: The Unit object raised to the specified power, or None if unit + Unit | None: The Unit object raised to the specified power, or None if `unit` is None. Raises: @@ -624,7 +672,8 @@ def __eq__(self, arg): """Check if this Unit object equals another. Parameters: - arg (Unit or None): The Unit object to compare with. + arg (Any): The object to compare with. Any object that is not a Unit compares + unequal. Returns: bool: True if the Unit objects are equal, False otherwise. @@ -639,7 +688,8 @@ def __ne__(self, arg): """Check if this Unit object does not equal another. Parameters: - arg (Unit or None): The Unit object to compare with. + arg (Any): The object to compare with. Any object that is not a Unit compares + unequal. Returns: bool: True if the Unit objects are not equal, False otherwise. @@ -655,6 +705,12 @@ def __ne__(self, arg): ###################################################################################### def __copy__(self): + """A shallow copy of this Unit. + + Returns: + Unit: A new Unit with the same exponents, triple, and name. + """ + return Unit(self.exponents, self.triple, self.name) def copy(self): @@ -693,12 +749,12 @@ def _mul_names(name1, name2): """Multiply two unit names. Parameters: - name1 (str, dict, or None): The first unit name. - name2 (str, dict, or None): The second unit name. + name1 (str | dict | None): The first unit name. + name2 (str | dict | None): The second unit name. Returns: - str or dict or None: The product of the two unit names, or None if both - arguments are None. + dict | None: The product of the two unit names, or None if either argument is + None. """ if name1 is None or name2 is None: @@ -726,12 +782,12 @@ def _div_names(name1, name2): """Divide two unit names. Parameters: - name1 (str, dict, or None): The numerator unit name. - name2 (str, dict, or None): The denominator unit name. + name1 (str | dict | None): The numerator unit name. + name2 (str | dict | None): The denominator unit name. Returns: - str or dict or None: The quotient of the two unit names, or None if both - arguments are None. + dict | None: The quotient of the two unit names, or None if either argument + is None. """ if name1 is None or name2 is None: @@ -759,11 +815,11 @@ def _name_power(name, power): """Raise a unit name to the specified power. Parameters: - name (str, dict, or None): The unit name to raise to a power. - power (int or float): The exponent. + name (str | dict | None): The unit name to raise to a power. + power (int | float): The exponent. Returns: - dict or None: The unit name raised to the specified power. The result is None + dict | None: The unit name raised to the specified power. The result is None if `name` is None, and also if the power would give any name a non-integer exponent, because no name written in these units can express the result. A unit left unnamed this way derives a name from its dimensions instead. @@ -801,7 +857,7 @@ def name_to_dict(expr): """Convert a unit expression string to a dictionary. Parameters: - expr (str or dict): The unit expression string to convert. It can contain "**" + expr (str | dict): The unit expression string to convert. It can contain "**" for exponentiation, "*" for multiply, and "/" for divide. It can contain nested substrings inside parentheses. @@ -817,7 +873,11 @@ def name_to_dict(expr): """ def parse_group(): - """Parse tokens up to the end or the next ")" and return their exponents.""" + """Parse tokens up to the end or the next ")" and return their exponents. + + Returns: + dict: The exponents of each unit name found in the group. + """ nonlocal pos @@ -877,19 +937,24 @@ def name_to_str(namedict): """Convert a unit name dictionary to a string. Parameters: - namedict (dict or None): The unit name dictionary to convert. + namedict (str | dict): The unit name dictionary to convert. A string is + returned unchanged. Returns: - str: A string representation of the unit name, or empty string if namedict is - None. - - Notes: - This method contains nested helper functions for ordering keys and - concatenating units. + str: A string representation of the unit name, or an empty string if + `namedict` is empty. """ def order_keys(namelist): - """Internal method to order the units sensibly.""" + """Internal method to order the units sensibly. + + Parameters: + namelist (list[str]): The unit names to sort. + + Returns: + list[str]: The names ordered with the coefficient first, then distances, + then angles, then times, then the remaining names alphabetically. + """ sorted_ = [] @@ -938,7 +1003,17 @@ def order_keys(namelist): return sorted_ def cat_units(namelist, negate=False): - """A string of names and exponents.""" + """A string of names and exponents. + + Parameters: + namelist (list[str]): The unit names to concatenate. + negate (bool, optional): True to negate each exponent, as required for the + denominator of a ratio. + + Returns: + str: The names joined by "*", each with its exponent where that is not + one. + """ unitlist = [] for key in namelist: @@ -991,7 +1066,7 @@ def create_name(self): """Create a name for this Unit object based on its exponents. Returns: - str or dict: A name for this Unit object. + str | dict: A name for this Unit object. """ # Return the internal name, if defined @@ -1015,7 +1090,7 @@ def _name_for_tuples(exponents, triple): conversion factor. Returns: - str or dict: The name, either a string or a dictionary of exponents keyed by + str | dict: The name, either a string or a dictionary of exponents keyed by unit name. """ @@ -1105,7 +1180,7 @@ def get_name(self): """Get the name of this Unit object. Returns: - str or dict or None: The name of this Unit object. + str: The name of this Unit object. """ name = self.name or self.create_name() @@ -1115,7 +1190,10 @@ def set_name(self, name): """Set the name of this Unit object. Parameters: - name (str or dict): The new name for this Unit object. + name (str | dict): The new name for this Unit object. + + Returns: + Unit: This object, with the new name applied. """ self.name = name @@ -1144,7 +1222,7 @@ def set_name(self, name): Unit.S = Unit((0, 1, 0), ( 1, 1, 0), 's') Unit.SEC = Unit((0, 1, 0), ( 1, 1, 0), 'sec') -Unit.SECOND = Unit((0, 1, 0), ( 1, 1, 0), 'second ') +Unit.SECOND = Unit((0, 1, 0), ( 1, 1, 0), 'second') Unit.SECONDS = Unit((0, 1, 0), ( 1, 1, 0), 'seconds') Unit.MIN = Unit((0, 1, 0), ( 60, 1, 0), 'min') Unit.MINUTE = Unit((0, 1, 0), ( 60, 1, 0), 'minute') @@ -1207,6 +1285,4 @@ def set_name(self, name): Unit._NAME_TO_UNIT[_unit.name] = _unit Unit._TUPLES_TO_UNIT[(_unit.exponents, _unit.triple)] = _unit -del _unit - ########################################################################################## diff --git a/src/polymath/unit.pyi b/src/polymath/unit.pyi deleted file mode 100644 index 541db43..0000000 --- a/src/polymath/unit.pyi +++ /dev/null @@ -1,137 +0,0 @@ -########################################################################################## -# polymath/unit.pyi -########################################################################################## -"""Type stub for :mod:`polymath.unit`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any, Self - -from polymath.qube import Qube, _ShapeOrTuple - -__all__ = ['Unit'] - -class Unit: - ARCHOUR: Unit - ARCHOURS: Unit - ARCMIN: Unit - ARCMINUTE: Unit - ARCMINUTES: Unit - ARCSEC: Unit - ARCSECOND: Unit - ARCSECONDS: Unit - CENTIMETER: Unit - CENTIMETERS: Unit - CM: Unit - CYCLE: Unit - CYCLES: Unit - D: Unit - DAY: Unit - DAYS: Unit - DEG: Unit - DEGREE: Unit - DEGREES: Unit - H: Unit - HOUR: Unit - HOURS: Unit - KILOMETER: Unit - KILOMETERS: Unit - KM: Unit - M: Unit - METER: Unit - METERS: Unit - MICRON: Unit - MICRONS: Unit - MILLIMETER: Unit - MILLIMETERS: Unit - MILLIRAD: Unit - MIN: Unit - MINUTE: Unit - MINUTES: Unit - MM: Unit - MRAD: Unit - MS: Unit - MSEC: Unit - RAD: Unit - RADIAN: Unit - RADIANS: Unit - REV: Unit - REVS: Unit - ROTATION: Unit - ROTATIONS: Unit - S: Unit - SEC: Unit - SECOND: Unit - SECONDS: Unit - STER: Unit - UNITLESS: Unit - def __copy__(self) -> Self: ... - def __div__(self, arg: Any) -> Any: ... - def __eq__(self, arg: object) -> Any: ... - def __init__(self, exponents: _ShapeOrTuple, triple: _ShapeOrTuple, - name: Any = ...) -> None: ... - def __mul__(self, arg: Any) -> Unit: ... - def __ne__(self, arg: object) -> Any: ... - def __pow__(self, power: float | builtins.int | bool) -> Unit: ... - def __rdiv__(self, arg: Any) -> Any: ... - def __repr__(self) -> str: ... - def __rmul__(self, arg: Any) -> Any: ... - def __rtruediv__(self, arg: Any) -> Unit: ... - def __str__(self) -> str: ... - def __truediv__(self, arg: Any) -> Unit: ... - @staticmethod - def as_unit(arg: Any) -> Any: ... - @staticmethod - def can_match(first: Unit | None, second: Unit | None) -> bool: ... - def convert(self, value: Any, unit: Unit | None, info: str = ...) -> Any: ... - def copy(self) -> Unit: ... - def create_name(self) -> str | dict[str, Qube]: ... - @staticmethod - def div_units(arg1: Unit | None, arg2: Unit | None) -> Any: ... - @staticmethod - def do_match(first: Unit | None, second: Unit | None) -> bool: ... - def from_this(self, value: Any) -> Any: ... - @staticmethod - def from_unit(unit: Unit | None, value: Any) -> Any: ... - @property - def from_unit_factor(self) -> Any: ... - def get_name(self) -> Any: ... - def into_this(self, value: Any) -> Any: ... - @staticmethod - def into_unit(unit: Unit | None, value: Any) -> Any: ... - @property - def into_unit_factor(self) -> Any: ... - @staticmethod - def is_angle(arg: Unit | None) -> bool: ... - @staticmethod - def is_unitless(arg: Unit | None) -> bool: ... - @staticmethod - def mul_units(arg1: Unit | None, arg2: Unit | None) -> Any: ... - @staticmethod - def name_to_dict(expr: Any) -> dict[str, builtins.int]: ... - @staticmethod - def name_to_str(namedict: Any) -> str: ... - @staticmethod - def require_angle(arg: Unit | None, info: str = ...) -> Any: ... - @staticmethod - def require_compatible(first: Unit | None, second: Unit | None, - info: str = ...) -> Any: ... - @staticmethod - def require_match(first: Unit | None, second: Unit | None, - info: str = ...) -> Any: ... - @staticmethod - def require_unitless(arg: Unit | None, info: str = ...) -> Any: ... - def set_name(self, name: Any) -> Any: ... - def sqrt(self) -> Unit: ... - @staticmethod - def sqrt_unit(unit: Unit | None) -> Any: ... - @staticmethod - def unit_power(unit: Unit | None, power: float | builtins.int | bool) -> Any: ... - -########################################################################################## diff --git a/src/polymath/vector.py b/src/polymath/vector.py index 25fe03c..8ea5552 100755 --- a/src/polymath/vector.py +++ b/src/polymath/vector.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/vector.py: Vector subclass of PolyMath base class ########################################################################################## +"""The :class:`~polymath.Vector` subclass, representing 1-D vectors of arbitrary length. + +A Vector has a numerator shape of ``(n,)``, so each of its items is a sequence of `n` +numbers. This class provides the vector algebra -- dot and cross products, outer products, +norms, and unit vectors -- along with the methods that convert between a Vector and the +Scalars that make up its components. +""" import numpy as np @@ -30,10 +37,10 @@ def __init__(self, arg, *args, **kwargs): """Initialize a Vector object. Parameters: - arg (numpy.ndarray, float, int, list, or tuple): The input data to construct - the Vector. A Python scalar will be converted to an array of shape (1,). - *args: Additional arguments passed to the Qube constructor. - **kwargs: Additional "keyword=value" arguments passd to the Qube + arg (VectorLike | float | int): The input data to construct the Vector. A + Python scalar will be converted to an array of shape (1,). + *args (Any): Additional arguments passed to the Qube constructor. + **kwargs (Any): Additional "keyword=value" arguments passed to the Qube constructor. If `drank` is specified, the input array must have at least `nrank + drank` dimensions. For example, with `drank=1`, the minimum shape is (n, m) where n is the numerator size and m is the denominator size. @@ -52,7 +59,7 @@ def as_vector(arg, *, recursive=True): """Convert the argument to a Vector if possible. Parameters: - arg (object): The object to convert to Vector. + arg (VectorLike): The object to convert to Vector. recursive (bool, optional): If True, derivatives will also be converted. Returns: @@ -66,7 +73,7 @@ def as_vector(arg, *, recursive=True): # Collapse a 1xN or Nx1 MatrixN down to a Vector if arg._nrank == 2 and (arg._numer[0] == 1 or arg._numer[1] == 1): - return arg.flatten_numer(Vector, recursive=recursive) + return arg.flatten_numer(classes=Vector, recursive=recursive) # Convert Scalar to shape (1,) if arg._nrank == 0: @@ -100,7 +107,7 @@ def to_scalar(self, indx, *, recursive=True): Scalar: The component at the specified index. """ - return self.extract_numer(0, indx, Scalar, recursive=recursive) + return self.extract_numer(0, indx, classes=Scalar, recursive=recursive) def to_scalars(self, *, recursive=True): """All the components of this Vector as a tuple of Scalars. @@ -109,28 +116,33 @@ def to_scalars(self, *, recursive=True): recursive (bool, optional): True to include the derivatives. Returns: - tuple: A tuple containing each component as a Scalar. + tuple[Scalar, ...]: A tuple containing each component as a Scalar. """ results = [] for i in range(self._numer[0]): - results.append(self.extract_numer(0, i, Scalar, recursive=recursive)) + results.append(self.extract_numer(0, i, classes=Scalar, + recursive=recursive)) return tuple(results) def to_pair(self, axes=(0, 1), *, recursive=True): """A Pair containing two selected components of this Vector. - Overrides the default method to include an 'axes' argument, which can extract any + Overrides the default method to include an `axes` argument, which can extract any two components of a Vector very efficiently. Parameters: - axes (tuple, optional): Indices of the two components to extract, positive or - negative. + axes (tuple[int, int], optional): Indices of the two components to extract, + positive or negative. recursive (bool, optional): If True, include derivatives in the result. Returns: Pair: A Pair object containing the two selected components. + + Raises: + IndexError: If an index in `axes` is out of range or the two indices refer to + the same component. """ size = self._numer[0] @@ -166,10 +178,10 @@ def from_scalars(*args, recursive=True, readonly=False): """Construct a Vector by combining scalar components. Parameters: - *args: Scalar objects defining the vector's components. They need not have the - same shape, but it must be possible to cast them to the same shape. A - value of None is converted to a zero-valued Scalar that matches the - denominator shape of the other arguments. + *args (ScalarLike | None): Scalar objects defining the vector's components. + They need not have the same shape, but it must be possible to cast them to + the same shape. A value of None is converted to a zero-valued Scalar that + matches the denominator shape of the other arguments. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives @@ -193,13 +205,12 @@ def as_index(self, *, masked=None): a tuple of N arrays, one for each component dimension. Parameters: - masked (scalar, list, tuple, or array-like, optional): The index or indices to - insert in place of masked items. If None and the object contains masked - elements, the array will be flattened and masked elements will be skipped - over. + masked (ScalarLike | None, optional): The index or indices to insert in place + of masked items. If None and the object contains masked elements, the + array will be flattened and masked elements will be skipped over. Returns: - tuple: A tuple of NumPy arrays suitable for indexing. + tuple[numpy.ndarray, ...]: A tuple of NumPy arrays suitable for indexing. """ (index, _mask) = self.as_index_and_mask(purge=(masked is None), masked=masked) @@ -211,14 +222,16 @@ def as_index_and_mask(self, *, purge=False, masked=None): Parameters: purge (bool, optional): True to eliminate masked elements from the index; False to retain them but leave them masked. - masked (scalar, optional): The index value to insert in place of any masked - item. This may be needed because each value in the returned index array - must be an integer and in range. If None, masked values in the index will - retain their unmasked values when the index is applied. + masked (ScalarLike | None, optional): The index value to insert in place of + any masked item. This may be needed because each value in the returned + index array must be an integer and in range. If None, masked values in the + index will retain their unmasked values when the index is applied. Returns: - tuple: A tuple containing (index, mask), where index is suitable for - indexing a NumPy ndarray and mask indicates which values are masked. + tuple[tuple[numpy.ndarray | numpy.integer, ...], MaskType]: A tuple + ``(index, mask)``, where `index` is suitable for indexing a NumPy ndarray and + `mask` indicates which values are masked. Each element of `index` is an + integer array, or a single integer if this object has shape (). Raises: TypeError: If this object contains floating-point values. @@ -271,21 +284,21 @@ def int(self, top=None, *, remask=False, clip=False, inclusive=True, shift=None) Class Scalar has a similar method :meth:`Scalar.int`. Parameters: - top (int or tuple, optional): Maximum integer value for each component, - equivalent to the array shape. Use a tuple to handle the components - differently; a single value applies to every component. + top (int | tuple[int, ...] | None, optional): Maximum integer value for each + component, equivalent to the array shape. Use a tuple to handle the + components differently; a single value applies to every component. remask (bool, optional): If True, values less than zero or greater than the specified top values (if provided) are masked. - clip (bool or tuple of bool, optional): If True, values less than zero or + clip (bool | tuple[bool, ...], optional): If True, values less than zero or greater than the specified top values are clipped. Use a tuple of booleans to handle the axes differently. - inclusive (bool or tuple of bool, optional): True to leave the top limits + inclusive (bool | tuple[bool, ...], optional): True to leave the top limits unmasked; False to mask them. Use a tuple of booleans to handle the axes differently. - shift (bool or tuple of bool, optional): True to shift any occurrences of the - top limit down by one; False to leave them unchanged. Use a tuple of - booleans to handle the axes differently. Default is None, which sets shift - to match the value of inclusive. + shift (bool | tuple[bool, ...] | None, optional): True to shift any + occurrences of the top limit down by one; False to leave them unchanged. + Use a tuple of booleans to handle the axes differently. Default is None, + which sets shift to match the value of inclusive. Returns: Vector: An integer version of this Vector. When remask=True, the mask may be @@ -297,8 +310,20 @@ def int(self, top=None, *, remask=False, clip=False, inclusive=True, shift=None) """ def _as_tuple(item, name): - # Quick internal method to make sure top, inclusive and shift are tuples or - # lists of the correct length. A single value applies to every component. + """One argument expanded to a tuple with one value per vector component. + + Parameters: + item (Any): A single value, or a list or tuple with one value per + component. + name (str): The parameter name, used in any error message. + + Returns: + tuple: One value per component of this Vector. + + Raises: + ValueError: If `item` is a list or tuple of the wrong length. + """ + if isinstance(item, (list, tuple)): if len(item) != self._numer[0]: raise ValueError(f'{type(self).__name__}.int() {name} does not match ' @@ -362,7 +387,7 @@ def _as_tuple(item, name): if remask: is_outside = Scalar.is_outside(self._values[..., k], 0, top[k], - inclusive[k]) + inclusive=inclusive[k]) if clip[k]: values[..., k] = np.clip(values[..., k], 0, top[k] - 1) @@ -374,7 +399,7 @@ def _as_tuple(item, name): result.__init__(values, mask, example=self) return result - def as_column(self, recursive=True): + def as_column(self, *, recursive=True): """Convert the Vector to an Nx1 column matrix. Parameters: @@ -384,7 +409,7 @@ def as_column(self, recursive=True): Matrix: An Nx1 matrix representation of this Vector. """ - return self.reshape_numer(self._numer + (1,), Qube._MATRIX_CLASS, + return self.reshape_numer(self._numer + (1,), classes=Qube._MATRIX_CLASS, recursive=recursive) def as_row(self, *, recursive=True): @@ -397,7 +422,7 @@ def as_row(self, *, recursive=True): Matrix: A 1xN matrix representation of this Vector. """ - return self.reshape_numer((1,) + self._numer, Qube._MATRIX_CLASS, + return self.reshape_numer((1,) + self._numer, classes=Qube._MATRIX_CLASS, recursive=recursive) def as_diagonal(self, *, recursive=True): @@ -411,13 +436,14 @@ def as_diagonal(self, *, recursive=True): of this Vector. """ - return Qube.as_diagonal(self, 0, Qube._MATRIX_CLASS, recursive=recursive) + return Qube.as_diagonal(self, 0, classes=Qube._MATRIX_CLASS, + recursive=recursive) def dot(self, arg, *, recursive=True): """Calculate the dot product of this vector and another. Parameters: - arg (Vector or vector-like): The vector to dot with this one. + arg (VectorLike): The vector to dot with this one. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -470,7 +496,7 @@ def with_norm(self, norm=1., *, recursive=True): """Scale this vector to the specified length. Parameters: - norm (float or Scalar, optional): The desired length. + norm (ScalarLike, optional): The desired length. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -489,12 +515,12 @@ def cross(self, arg, *, recursive=True): """Calculate the cross product of this vector with another. Parameters: - arg (Vector or vector-like): The vector to cross with this one. + arg (VectorLike): The vector to cross with this one. recursive (bool, optional): If True, include derivatives in the result. Returns: - Vector: The cross product vector. For 3-vectors, returns a Vector; for - 2-vectors, returns a Scalar. + Vector | Scalar: The cross product. For 3-vectors, this is a Vector; for + 2-vectors, it is a Scalar. """ arg = self.as_this_type(arg, recursive=recursive, coerce=False) @@ -509,7 +535,7 @@ def ucross(self, arg, *, recursive=True): Works only for vectors of length 3. Parameters: - arg (Vector or vector-like): The vector to cross with this one. + arg (VectorLike): The vector to cross with this one. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -522,7 +548,7 @@ def outer(self, arg, *, recursive=True): """The outer product of two vectors, resulting in a Matrix. Parameters: - arg (Vector or vector-like): The vector to compute the outer product with. + arg (VectorLike): The vector to compute the outer product with. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -530,14 +556,13 @@ def outer(self, arg, *, recursive=True): """ arg = Vector.as_vector(arg, recursive=recursive) - return Qube.outer(self, arg, Qube._MATRIX_CLASS, recursive=recursive) + return Qube.outer(self, arg, classes=Qube._MATRIX_CLASS, recursive=recursive) def perp(self, arg, *, recursive=True): """The component of this vector perpendicular to another. Parameters: - arg (Vector or vector-like): The vector to calculate perpendicular component - with. + arg (VectorLike): The vector to calculate perpendicular component with. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -556,7 +581,7 @@ def proj(self, arg, *, recursive=True): """The component of this vector projected onto another. Parameters: - arg (Vector or vector-like): The vector to project onto. + arg (VectorLike): The vector to project onto. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -575,8 +600,7 @@ def sep(self, arg, *, recursive=True): Works for vectors of length 2 or 3. Parameters: - arg (Vector or vector-like): The vector to calculate the separation angle - with. + arg (VectorLike): The vector to calculate the separation angle with. recursive (bool, optional): If True, include derivatives in the result. Returns: @@ -657,15 +681,15 @@ def element_mul(self, arg, *, recursive=True): """Perform element-by-element multiplication of two vectors. Parameters: - arg (Vector or vector-like): The vector to multiply element-wise. + arg (VectorLike): The vector to multiply element-wise. recursive (bool, optional): If True, include derivatives in the result. Returns: Vector: The element-wise product of this vector and the argument. Raises: - ValueError: If the numerator shapes are incompatible or if both this - vector and the argument have denominators. + ValueError: If the numerator shapes are incompatible or if both this vector + and the argument have denominators. """ # Convert to this class if necessary @@ -719,24 +743,27 @@ def element_mul(self, arg, *, recursive=True): return obj - def element_div(self, arg, recursive=True): + def element_div(self, arg, *, recursive=True): """Perform element-by-element division of two vectors. Parameters: - arg (Vector or vector-like): The vector to divide by element-wise. + arg (VectorLike): The vector to divide by element-wise. recursive (bool, optional): If True, include derivatives in the result. Returns: Vector: The element-wise division of this vector by the argument. Raises: - ValueError: If the numerator shapes are incompatible or if the argument - has a denominator. + ValueError: If the numerator shapes are incompatible or if the argument has a + denominator. """ # Convert to this class if necessary - if not isinstance(arg, Qube): - arg = self.as_this_type(arg, recursive=recursive, coerce=False) + original_arg = arg + arg = self.as_this_type(arg, recursive=recursive, coerce=False) + + # If it had no unit originally, it should not have a unit now + if not isinstance(original_arg, Qube): arg = arg.without_unit() # Validate @@ -801,7 +828,7 @@ def element_div(self, arg, recursive=True): return obj - def vector_scale(self, factor, recursive=True): + def vector_scale(self, factor, *, recursive=True): """Stretch this Vector along a direction defined by a scaling vector. Components of the vector perpendicular to the scaling vector are unchanged. The @@ -825,7 +852,7 @@ def vector_scale(self, factor, recursive=True): else: return self.wod + (projected.norm() - 1) * projected - def vector_unscale(self, factor, recursive=True): + def vector_unscale(self, factor, *, recursive=True): """Un-stretch this Vector along a direction defined by a scaling vector. Components of the vector perpendicular to the scaling vector are unchanged. @@ -850,7 +877,7 @@ def combos(cls, *args): ignored. Parameters: - *args: Scalar objects to combine. + *args (ScalarLike): Scalar objects to combine. Returns: Vector: A vector with shape defined by concatenating the shapes of all the @@ -894,7 +921,7 @@ def combos(cls, *args): return cls(data, mask) - def mask_where_component_le(self, axis, limit, replace=None, remask=True): + def mask_where_component_le(self, axis, limit, *, replace=None, remask=True): """A copy with masked values where a component is <= a limit. Creates a copy of this object where values of a specified component that @@ -902,10 +929,10 @@ def mask_where_component_le(self, axis, limit, replace=None, remask=True): Parameters: axis (int): The index of the component to use for comparison. - limit (scalar or Scalar): The limiting value or a Scalar of limiting values. - replace (scalar or array-like, optional): A single replacement value or an - array of replacement values, inserted at every masked location. Use None - to leave values unchanged. + limit (ScalarLike): The limiting value or a Scalar of limiting values. + replace (ScalarLike | None, optional): A single replacement value or an array + of replacement values, inserted at every masked location. Use None to + leave values unchanged. remask (bool, optional): True to include the new mask in the object's mask; False to replace the values but leave them unmasked. @@ -918,7 +945,7 @@ def mask_where_component_le(self, axis, limit, replace=None, remask=True): scalar = self.to_scalar(axis) return self.mask_where(scalar <= limit, replace=replace, remask=remask) - def mask_where_component_ge(self, axis, limit, replace=None, remask=True): + def mask_where_component_ge(self, axis, limit, *, replace=None, remask=True): """A copy with masked values where a component is >= a limit. Creates a copy of this object where values of a specified component that @@ -926,10 +953,10 @@ def mask_where_component_ge(self, axis, limit, replace=None, remask=True): Parameters: axis (int): The index of the component to use for comparison. - limit (scalar or Scalar): The limiting value or a Scalar of limiting values. - replace (scalar or array-like, optional): A single replacement value or an - array of replacement values, inserted at every masked location. Use None - to leave values unchanged. + limit (ScalarLike): The limiting value or a Scalar of limiting values. + replace (ScalarLike | None, optional): A single replacement value or an array + of replacement values, inserted at every masked location. Use None to + leave values unchanged. remask (bool, optional): True to include the new mask in the object's mask; False to replace the values but leave them unmasked. @@ -942,7 +969,7 @@ def mask_where_component_ge(self, axis, limit, replace=None, remask=True): scalar = self.to_scalar(axis) return self.mask_where(scalar >= limit, replace=replace, remask=remask) - def mask_where_component_lt(self, axis, limit, replace=None, remask=True): + def mask_where_component_lt(self, axis, limit, *, replace=None, remask=True): """A copy with masked values where a component is < a limit. Creates a copy of this object where values of a specified component that @@ -950,10 +977,10 @@ def mask_where_component_lt(self, axis, limit, replace=None, remask=True): Parameters: axis (int): The index of the component to use for comparison. - limit (scalar or Scalar): The limiting value or a Scalar of limiting values. - replace (scalar or array-like, optional): A single replacement value or an - array of replacement values, inserted at every masked location. Use None - to leave values unchanged. + limit (ScalarLike): The limiting value or a Scalar of limiting values. + replace (ScalarLike | None, optional): A single replacement value or an array + of replacement values, inserted at every masked location. Use None to + leave values unchanged. remask (bool, optional): True to include the new mask in the object's mask; False to replace the values but leave them unmasked. @@ -965,7 +992,7 @@ def mask_where_component_lt(self, axis, limit, replace=None, remask=True): scalar = self.to_scalar(axis) return self.mask_where(scalar < limit, replace=replace, remask=remask) - def mask_where_component_gt(self, axis, limit, replace=None, remask=True): + def mask_where_component_gt(self, axis, limit, *, replace=None, remask=True): """A copy with masked values where a component is > a limit. Creates a copy of this object where values of a specified component that @@ -973,10 +1000,10 @@ def mask_where_component_gt(self, axis, limit, replace=None, remask=True): Parameters: axis (int): The index of the component to use for comparison. - limit (scalar or Scalar): The limiting value or a Scalar of limiting values. - replace (scalar or array-like, optional): A single replacement value or an - array of replacement values, inserted at every masked location. Use None - to leave values unchanged. + limit (ScalarLike): The limiting value or a Scalar of limiting values. + replace (ScalarLike | None, optional): A single replacement value or an array + of replacement values, inserted at every masked location. Use None to + leave values unchanged. remask (bool, optional): True to include the new mask in the object's mask; False to replace the values but leave them unmasked. @@ -989,7 +1016,7 @@ def mask_where_component_gt(self, axis, limit, replace=None, remask=True): scalar = self.to_scalar(axis) return self.mask_where(scalar > limit, replace=replace, remask=remask) - def clip_component(self, axis, lower, upper, remask=False): + def clip_component(self, axis, lower, upper, *, remask=False): """A copy with component values clipped to specified range. Creates a copy of this object where values of a specified component that are @@ -998,9 +1025,9 @@ def clip_component(self, axis, lower, upper, remask=False): Parameters: axis (int): The index of the component to use for comparison. - lower (scalar or Scalar): The lower limit for clipping; None to ignore. This + lower (ScalarLike | None): The lower limit for clipping; None to ignore. This can be a single scalar or a Scalar object of the same shape as the object. - upper (scalar or Scalar): The upper limit for clipping; None to ignore. This + upper (ScalarLike | None): The upper limit for clipping; None to ignore. This can be a single scalar or a Scalar object of the same shape as the object. remask (bool, optional): True to mask the clipped values in the object's mask; False to replace the values but leave them unmasked. @@ -1054,7 +1081,7 @@ def clip_component(self, axis, lower, upper, remask=False): # Overrides of superclass operators ############################################################################ - def __abs__(self, recursive=True): + def __abs__(self, *, recursive=True): """The Euclidean norm of this Vector. Parameters: @@ -1075,8 +1102,8 @@ def identity(self): Qube._raise_unsupported_op('identity()', self) - def reciprocal(self, nozeros=False): - """The reciprocal of this Vector as a Jacobian.. + def reciprocal(self, *, nozeros=False): + """The reciprocal of this Vector as a Jacobian. This Vector must be a Jacobian, i.e., the derivative of one Vector with respect to another. The reciprocal is therefore the matrix inverse, the derivative of the @@ -1089,9 +1116,14 @@ def reciprocal(self, nozeros=False): determinants. Set to True only if you know in advance that all determinants are nonzero. + Returns: + Vector: The matrix inverse of this Jacobian. + Raises: + TypeError: If this Vector does not have exactly one denominator axis, so that + it does not represent a Jacobian. ValueError: If the two Vectors do not have the same dimension (meaning the - matrix in not square). + matrix is not square). ValueError: If `nozeros` is True but a determinant of zero is encountered. """ diff --git a/src/polymath/vector.pyi b/src/polymath/vector.pyi deleted file mode 100644 index 4a99fca..0000000 --- a/src/polymath/vector.pyi +++ /dev/null @@ -1,80 +0,0 @@ -########################################################################################## -# polymath/vector.pyi -########################################################################################## -"""Type stub for :mod:`polymath.vector`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -import builtins -from typing import Any - -from polymath.qube import Qube, _Arraylike, _ShapeOrTuple - -__all__ = ['Vector'] - -class Vector(Qube): - MASKED2: Vector - MASKED3: Vector - XAXIS2: Vector - XAXIS3: Vector - YAXIS2: Vector - YAXIS3: Vector - ZAXIS3: Vector - ZERO2: Vector - ZERO3: Vector - def __abs__(self, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def __init__(self, arg: Any, *args: Any, **kwargs: Any) -> None: ... - def as_column(self, recursive: bool = ...) -> _Arraylike: ... - def as_diagonal(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def as_index(self, *, masked: Any = ...) -> _ShapeOrTuple: ... - def as_index_and_mask(self, *, purge: bool = ..., - masked: Any = ...) -> _ShapeOrTuple: ... - def as_row(self, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def as_vector(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def clip_component(self, axis: builtins.int, lower: Any, upper: Any, - remask: bool = ...) -> _Arraylike: ... - @classmethod - def combos(cls, *args: Any) -> _Arraylike: ... - def cross(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def cross_product_as_matrix(self, *, recursive: bool = ...) -> _Arraylike: ... - def dot(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def element_div(self, arg: Any, recursive: bool = ...) -> _Arraylike: ... - def element_mul(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_scalars(*args: Any, recursive: bool = ..., # type: ignore[override] - readonly: bool = ...) -> _Arraylike: ... - def identity(self) -> Any: ... - def int(self, top: Any = ..., *, remask: bool = ..., clip: Any = ..., - inclusive: Any = ..., shift: Any = ...) -> _Arraylike: ... - def mask_where_component_ge(self, axis: builtins.int, limit: Any, replace: Any = ..., - remask: bool = ...) -> _Arraylike: ... - def mask_where_component_gt(self, axis: builtins.int, limit: Any, replace: Any = ..., - remask: bool = ...) -> _Arraylike: ... - def mask_where_component_le(self, axis: builtins.int, limit: Any, replace: Any = ..., - remask: bool = ...) -> _Arraylike: ... - def mask_where_component_lt(self, axis: builtins.int, limit: Any, replace: Any = ..., - remask: bool = ...) -> _Arraylike: ... - def norm(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def norm_sq(self, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def outer(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... # type: ignore[override] - def perp(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def proj(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def reciprocal(self, nozeros: bool = ...) -> Any: ... # type: ignore[override] - def sep(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def to_pair(self, axes: _ShapeOrTuple = ..., *, - recursive: bool = ...) -> _Arraylike: ... - def to_scalar(self, indx: builtins.int, *, recursive: bool = ...) -> _Arraylike: ... - def to_scalars(self, *, recursive: bool = ...) -> _ShapeOrTuple: ... - def ucross(self, arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - def unit(self, *, recursive: bool = ...) -> _Arraylike: ... - def vector_scale(self, factor: _Arraylike, recursive: bool = ...) -> _Arraylike: ... - def vector_unscale(self, factor: _Arraylike, recursive: bool = ...) -> _Arraylike: ... - def with_norm(self, norm: Any = ..., *, recursive: bool = ...) -> _Arraylike: ... - -########################################################################################## diff --git a/src/polymath/vector3.py b/src/polymath/vector3.py index bbe9980..20ef137 100755 --- a/src/polymath/vector3.py +++ b/src/polymath/vector3.py @@ -1,6 +1,13 @@ ########################################################################################## # polymath/vector3.py: Vector3 subclass of PolyMath Vector ########################################################################################## +"""The :class:`~polymath.Vector3` subclass, representing 3-dimensional vectors. + +A Vector3 is a :class:`~polymath.Vector` whose numerator shape is fixed at ``(3,)``. +Beyond the general vector algebra, it supports the coordinate conversions used in +geometry: spherical (right ascension and declination), cylindrical, and +longitude/latitude, plus rotations about an arbitrary pole. +""" import numpy as np import numbers @@ -33,20 +40,21 @@ def as_vector3(arg, *, recursive=True): """Convert the argument to Vector3 if possible. Parameters: - arg (object): The object to convert to Vector3. - recursive (bool, optional): If True, derivatives will also be - converted. + arg (Vector3Like): The object to convert to Vector3. + recursive (bool, optional): If True, derivatives will also be converted. Returns: Vector3: The converted Vector3 object. + Raises: + ValueError: If the input cannot be converted to a 3-component vector. + Notes: Conversion is possible from: Vector objects with 3 components, 1x3 or 3x1 - Matrix objects (which are flattened to Vector3), arrays/list/tuples with 3 - elements, or other Qube objects with compatible shapes. For Qube objects with - rank > 1 where the first numerator dimension is 3, the numerator items are - split to create a Vector3. Raises ValueError if the input cannot be converted - to a 3-component vector. + Matrix objects (which are flattened to Vector3), arrays, lists or tuples with + 3 elements, or other Qube objects with compatible shapes. For Qube objects + with rank > 1 where the first numerator dimension is 3, the numerator items + are split to create a Vector3. """ if isinstance(arg, Vector3): @@ -56,11 +64,11 @@ def as_vector3(arg, *, recursive=True): # Collapse a 1x3 or 3x1 Matrix down to a Vector if arg._numer in ((1, 3), (3, 1)): - return arg.flatten_numer(Vector3, recursive=recursive) + return arg.flatten_numer(classes=Vector3, recursive=recursive) # For any suitable Qube, move numerator items to the denominator if arg.rank > 1 and arg._numer[0] == 3: - arg = arg.split_items(1, Vector3) + arg = arg.split_items(1, classes=Vector3) arg = Vector3(arg) return arg if recursive else arg.wod @@ -72,9 +80,9 @@ def from_scalars(x, y, z, *, recursive=True, readonly=False): """Construct a Vector3 by combining three scalars. Parameters: - x (Scalar or convertible): First component of the vector. - y (Scalar or convertible): Second component of the vector. - z (Scalar or convertible): Third component of the vector. + x (ScalarLike | None): First component of the vector. + y (ScalarLike | None): Second component of the vector. + z (ScalarLike | None): Third component of the vector. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives found among x, y and z. @@ -132,10 +140,10 @@ def from_ra_dec_length(ra, dec, length=1., *, recursive=True): """Construct a Vector3 from right ascension, declination and optional length. Parameters: - ra (Scalar): Right ascension in radians. - dec (Scalar): Declination in radians. - length (Scalar, optional): Length of the vector. Defaults to 1.0, producing a - unit vector. + ra (ScalarLike): Right ascension in radians. + dec (ScalarLike): Declination in radians. + length (ScalarLike, optional): Length of the vector. Defaults to 1.0, + producing a unit vector. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives in ra, dec and length. @@ -172,10 +180,9 @@ def to_ra_dec_length(self, *, recursive=True): recursive (bool, optional): True to include the derivatives. Returns: - tuple: A tuple `(ra, dec, length)` where all three are Scalars. **ra** and - **dec** are in radians. **ra** is the right ascension (azimuthal angle in the - XY plane), **dec** is the declination (elevation angle from the XY plane), and - **length** is the magnitude of the vector. + tuple[Scalar, Scalar, Scalar]: `(ra, dec, length)` where `ra` is the right + ascension in radians, `dec` is the declination in radians, and `length` is the + magnitude of the vector. """ (x, y, z) = self.to_scalars(recursive=recursive) @@ -191,14 +198,13 @@ def from_cylindrical(radius, longitude, z=0., *, recursive=True): """Construct a Vector3 from cylindrical coordinates. Parameters: - radius (Scalar): Distance from the cylindrical axis. - longitude (Scalar): Longitude in radians. Zero is along the x-axis, with - positive values measured counterclockwise toward the y-axis. - z (Scalar, optional): Distance above/below the equatorial plane (positive z - is above the XY plane). + radius (ScalarLike): Distance from the cylindrical axis. + longitude (ScalarLike): Longitude in radians. Zero is along the **X**-axis, + with positive values measured counterclockwise toward the **Y**-axis. + z (ScalarLike, optional): Distance above the **XY** plane. recursive (bool, optional): True to include all the derivatives. The returned object will have derivatives representing the union of all the derivatives - in radius, longitude and z. + in `radius`, `longitude`, and `z`. Returns: Vector3: A new Vector3 object constructed from the cylindrical coordinates. @@ -206,7 +212,7 @@ def from_cylindrical(radius, longitude, z=0., *, recursive=True): Notes: Input arguments need not have the same shape, but it must be possible to cast them to the same shape. The coordinate system uses: x-axis as reference - (longitude=0), y-axis at longitude=π/2, z-axis perpendicular to the xy-plane. + (longitude=0), y-axis at longitude=pi/2, z-axis perpendicular to the xy-plane. """ radius = Scalar.as_scalar(radius, recursive=recursive) @@ -219,16 +225,16 @@ def from_cylindrical(radius, longitude, z=0., *, recursive=True): return Vector3.from_scalars(x, y, z, recursive=recursive) def to_cylindrical(self, *, recursive=True): - """A tuple (radius, longitude, z) from this Vector3. + """A tuple `(radius, longitude, z)` from this Vector3. Parameters: recursive (bool, optional): True to include the derivatives. Returns: - tuple: A tuple `(radius, longitude, z)` where all three are Scalars. - **radius** is the distance from the cylindrical axis (sqrt(x² + y²)), - **longitude** is in radians (measured from the x-axis toward the y-axis, - range [0, 2π)), and **z** is the distance above/below the equatorial plane. + tuple[Scalar, Scalar, Scalar]: `(radius, longitude, z)` where `radius` is the + distance from the cylindrical axis (sqrt(x**2 + y**2)), `longitude` is the + angle in radians from the **X**-axis toward the **Y**-axis in the range [0, + 2*pi), and `z` is the distance above/below the equatorial plane. """ (x, y, z) = self.to_scalars(recursive=recursive) @@ -245,9 +251,9 @@ def longitude(self, *, recursive=True): recursive (bool, optional): True to include the derivatives. Returns: - Scalar: The longitude in radians, measured from the X-axis toward the Y-axis. - The longitude is returned in the range [0, 2π) radians, measured - counterclockwise from the positive X-axis in the XY plane. + Scalar: The longitude in radians, measured from the **X**-axis toward the + **Y**-axis. The longitude is returned in the range [0, 2*pi) radians, measured + counterclockwise from the positive **X**-axis in the **XY** plane. """ x = self.to_scalar(0, recursive=recursive) @@ -262,9 +268,9 @@ def latitude(self, *, recursive=True): Returns: Scalar: The latitude in radians, measured from the equatorial plane toward - the Z-axis. The latitude is returned in the range [-π/2, π/2] radians, where - positive values are above the equatorial plane (positive Z) and negative - values are below. + the **Z**-axis. The latitude is returned in the range [-pi/2, pi/2] radians, + where positive values are above the equatorial plane (positive **Z**) and + negative values are below. """ z = self.to_scalar(2, recursive=recursive) @@ -295,9 +301,9 @@ def spin(self, pole, angle=None, *, recursive=True): """This Vector3 rotated about a pole vector. Parameters: - pole (Vector3): The pole vector about which to rotate. - angle (Scalar, optional): The rotation angle in radians. If None, the angle is - determined from the pole vector's magnitude. + pole (Vector3Like): The pole vector about which to rotate. + angle (ScalarLike | None, optional): The rotation angle in radians. If None, + the angle is determined from the pole vector's magnitude. recursive (bool, optional): True to include the derivatives. Returns: @@ -336,15 +342,14 @@ def offset_angles(self, vector, *, recursive=True): """The angular offset between this Vector3 and another. Parameters: - vector (Vector3): The vector to measure the offset from. + vector (Vector3Like): The vector to measure the offset from. recursive (bool, optional): True to include the derivatives. Returns: - tuple: A tuple `(longitude_offset, latitude_offset)` where both are Scalars - in radians. These are the angular offsets needed to rotate from this vector - to the target vector. The first rotation is about the Y-axis - (longitude_offset), followed by a rotation about the X-axis - (latitude_offset). Positive angles follow the right-hand rule. + tuple[Scalar, Scalar]: `(longitude_offset, latitude_offset)`, the angular + offsets needed to rotate from this vector to the target vector. + `longitude_offset` is about the **Y**-axis, followed by `latitude_offset` + about the **X**-axis. Angles are in radians and follow the right-hand rule. """ vector = Vector3.as_vector3(vector, recursive=recursive) diff --git a/src/polymath/vector3.pyi b/src/polymath/vector3.pyi deleted file mode 100644 index e605e45..0000000 --- a/src/polymath/vector3.pyi +++ /dev/null @@ -1,50 +0,0 @@ -########################################################################################## -# polymath/vector3.pyi -########################################################################################## -"""Type stub for :mod:`polymath.vector3`. - -The `src` tree carries no inline annotations, so type information for public symbols is -published here instead. These stubs describe the shape of the API exactly: every public -name, its parameters, which of them are keyword-only, and which have defaults. Types are -taken from the docstrings wherever those state one unambiguously, and are left as `Any` -where they do not, rather than guessed at. -""" - -from typing import Any - -from polymath.qube import _Arraylike, _ShapeOrTuple -from polymath.vector import Vector - -__all__ = ['Vector3'] - -class Vector3(Vector): - AXES: tuple[Any, ...] - IDENTITY: Vector3 - MASKED: Vector3 - ONES: Vector3 - XAXIS: Vector3 - YAXIS: Vector3 - ZAXIS: Vector3 - ZERO: Vector3 - ZERO_POS_VEL: Vector3 - @staticmethod - def as_vector3(arg: Any, *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_cylindrical(radius: _Arraylike, longitude: _Arraylike, z: _Arraylike = ..., - *, recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_ra_dec_length(ra: _Arraylike, dec: _Arraylike, length: _Arraylike = ..., *, - recursive: bool = ...) -> _Arraylike: ... - @staticmethod - def from_scalars(x: Any, y: Any, z: Any, *, recursive: bool = ..., # type: ignore[override] - readonly: bool = ...) -> _Arraylike: ... - def latitude(self, *, recursive: bool = ...) -> _Arraylike: ... - def longitude(self, *, recursive: bool = ...) -> _Arraylike: ... - def offset_angles(self, vector: _Arraylike, *, - recursive: bool = ...) -> _ShapeOrTuple: ... - def spin(self, pole: _Arraylike, angle: _Arraylike | None = ..., *, - recursive: bool = ...) -> _Arraylike: ... - def to_cylindrical(self, *, recursive: bool = ...) -> _ShapeOrTuple: ... - def to_ra_dec_length(self, *, recursive: bool = ...) -> _ShapeOrTuple: ... - -########################################################################################## diff --git a/tests/test_boolean.py b/tests/test_boolean.py index 4d327fe..450b50e 100755 --- a/tests/test_boolean.py +++ b/tests/test_boolean.py @@ -139,9 +139,9 @@ def test_boolean_zeros() -> None: assert np.all(a.vals == True) assert np.all(a.mask == [[0,1],[0,0]]) with pytest.raises(ValueError): - Boolean.ones(7, (2,3), numer=(3,)) + Boolean.ones(7, (2,3), numer=(3,)) # type: ignore[arg-type] # deliberately the wrong type with pytest.raises(ValueError): - Boolean.ones(7, (2,3), denom=(3,)) + Boolean.ones(7, (2,3), denom=(3,)) # type: ignore[arg-type] # deliberately the wrong type ################################################################################## # as_boolean diff --git a/tests/test_indices.py b/tests/test_indices.py index 0bb5a04..aef8aa1 100755 --- a/tests/test_indices.py +++ b/tests/test_indices.py @@ -3,8 +3,11 @@ ########################################################################################## import warnings +from typing import Any + import numpy as np import pytest +from numpy.ma import MaskedArray from polymath import Scalar, Pair, Vector, Matrix, Boolean, Qube @@ -12,11 +15,11 @@ def test_indices_an_unmasked_scalar() -> None: """An unmasked Scalar.""" - def make_masked(orig, mask_list): + def make_masked(orig: MaskedArray, mask_list: Any) -> MaskedArray: ret = orig.copy() ret[np.array(mask_list)] = np.ma.masked return ret - def extract(a, indices): + def extract(a: MaskedArray, indices: Any) -> MaskedArray: ret = [] for index in indices: ret.append(a[index]) @@ -28,7 +31,7 @@ def extract(a, indices): result = np.ma.array(ret) return result - def compare_a_b_1d(a, b, class_): + def compare_a_b_1d(a: Qube, b: MaskedArray, class_: type[Qube]) -> None: """Input a is a Qube subclass made from MaskedArray b, at least 1-D.""" # Traditional indexing @@ -64,7 +67,7 @@ def compare_a_b_1d(a, b, class_): assert a[Boolean(False)].shape == (0,) + a.shape[1:] assert a[Boolean.MASKED].shape == (1,) + a.shape[1:] assert a[Boolean.MASKED].mask == True - def compare_a_b_2d(a, b, class_): + def compare_a_b_2d(a: Qube, b: MaskedArray, class_: type[Qube]) -> None: """Input a is a Qube subclass made from MaskedArray b, at least 2-D.""" assert a[Pair((1,1))] == b[1,1] @@ -76,7 +79,7 @@ def compare_a_b_2d(a, b, class_): assert a[Pair(((1,1),(2,2),(3,3)),(True,False,False))] == make_masked(extract(b, ((1,1),(2,2),(3,3))), [0]) assert a[Pair(((1,1),(2,2),(3,3)),(False,True,False))] == make_masked(extract(b, ((1,1),(2,2),(3,3))), [1]) assert a[Pair(((1,1),(2,2),(3,3)),(False,False,True))] == make_masked(extract(b, ((1,1),(2,2),(3,3))), [2]) - def compare_a_b_3d(a, b, class_): + def compare_a_b_3d(a: Qube, b: MaskedArray, class_: type[Qube]) -> None: """Input a is a Qube subclass made from MaskedArray b, at least 3-D. """ @@ -112,7 +115,7 @@ def compare_a_b_3d(a, b, class_): indx = (Scalar([1,2],True), Ellipsis, Scalar([0,1],True)) assert np.all(a[indx].mask == True) - def check_derivs_1d(c): + def check_derivs_1d(c: Qube) -> None: """Alternative ways of indexing a 1-D derivative.""" assert c[1].d_dt == c.d_dt[1] @@ -133,7 +136,7 @@ def check_derivs_1d(c): assert c[:].d_dxy == c.d_dxy assert c[...].d_dxy == c.d_dxy assert c[::-1].d_dxy == c.d_dxy[::-1] - def check_derivs_2d(c, ellipses=True): + def check_derivs_2d(c: Qube, ellipses: bool = True) -> None: """Alternative ways of indexing a 2-D derivative.""" assert c[1,0].d_dt == c.d_dt[1,0] @@ -166,7 +169,7 @@ def check_derivs_2d(c, ellipses=True): assert c[Pair((-1,0))].d_dt == c.d_dt.vals[-1,0] assert c[Pair([(1,3),(2,3),(3,3),(4,3)])].d_dt == c.d_dt[1:5,3] - b = np.ma.arange(10) + b: Any = np.ma.arange(10) a = Scalar(b.data, False) c = a.copy() c.insert_deriv('t', Scalar([5,4,3,2,1,0,9,8,7,6])) @@ -657,7 +660,7 @@ def test_indices_masked_index_when_every_element_of_the_axis_is_used() -> None: index = Scalar([0, 1, 2, 0], [False, False, False, True]) result = a[index] - assert list(result.mask) == [False, False, False, True] + assert list(np.asarray(result.mask)) == [False, False, False, True] assert result.values[0] == 10. assert result.values[1] == 11. assert result.values[2] == 12. @@ -670,7 +673,7 @@ def test_indices_masked_index_avoids_the_elements_the_index_selects() -> None: index = Scalar([1, 2, 1], [False, False, True]) result = a[index] - assert list(result.mask) == [False, False, True] + assert list(np.asarray(result.mask)) == [False, False, True] assert result.values[0] == 11. assert result.values[1] == 12. # The value under the mask is unspecified, but it must not alias an element that the diff --git a/tests/test_math_ops_coverage.py b/tests/test_math_ops_coverage.py index dcc699c..ef7e239 100644 --- a/tests/test_math_ops_coverage.py +++ b/tests/test_math_ops_coverage.py @@ -49,7 +49,7 @@ def test_math_ops_coverage_test_incompatible_types() -> None: a = Scalar([1., 2., 3.]) with pytest.raises(TypeError) as cm: - a += "invalid" + a += "invalid" # type: ignore[arg-type] # deliberately the wrong type assert 'unsupported operand type' in str(cm.value) a = Scalar([1, 2, 3]) # Integer @@ -165,7 +165,7 @@ def test_math_ops_coverage_test_incompatible_types() -> None: a = Scalar([1., 2., 3.]) with pytest.raises(TypeError) as cm: - a /= object() + a /= object() # type: ignore[arg-type] # deliberately the wrong type assert 'unsupported operand type' in str(cm.value) a = Scalar([7, 8, 9]) @@ -194,7 +194,7 @@ def test_math_ops_coverage_test_incompatible_types() -> None: a = Scalar([5., 7., 9.]) with pytest.raises(TypeError) as cm: - a //= object() + a //= object() # type: ignore[arg-type] # deliberately the wrong type assert 'unsupported operand type' in str(cm.value) a = Scalar([7, 8, 9]) @@ -230,7 +230,7 @@ def test_math_ops_coverage_test_incompatible_types() -> None: a = Scalar([5., 7., 9.]) with pytest.raises(TypeError) as cm: - a %= object() + a %= object() # type: ignore[arg-type] # deliberately the wrong type assert 'unsupported operand type' in str(cm.value) a = Scalar([2., 3., 4.]) diff --git a/tests/test_matrix3.py b/tests/test_matrix3.py index 68cbe3a..27b8f64 100644 --- a/tests/test_matrix3.py +++ b/tests/test_matrix3.py @@ -6,8 +6,7 @@ import numpy as np import pytest -from polymath import Matrix3, Matrix, Vector, Vector3, Scalar, Quaternion -from polymath.unit import Unit +from polymath import Matrix3, Matrix, Vector, Vector3, Scalar, Quaternion, Unit def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() -> None: @@ -81,8 +80,8 @@ def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() assert rx.shape == () assert rx.numer == (3, 3) expected = np.array([[1., 0., 0.], - [0., np.cos(angle), np.sin(angle)], - [0., -np.sin(angle), np.cos(angle)]]) + [0., np.cos(angle), -np.sin(angle)], + [0., np.sin(angle), np.cos(angle)]]) assert np.allclose(rx.vals, expected, atol=DEL) angles = np.array([0., np.pi/4, np.pi/2]) @@ -90,8 +89,8 @@ def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() assert rx_array.shape == (3,) for i, angle in enumerate(angles): expected = np.array([[1., 0., 0.], - [0., np.cos(angle), np.sin(angle)], - [0., -np.sin(angle), np.cos(angle)]]) + [0., np.cos(angle), -np.sin(angle)], + [0., np.sin(angle), np.cos(angle)]]) assert np.allclose(rx_array.vals[i], expected, atol=DEL) ry = Matrix3.y_rotation(angle) @@ -293,7 +292,7 @@ def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() assert m2.shape == m.shape with pytest.raises(TypeError): - Matrix3(np.eye(3), unit='km') + Matrix3(np.eye(3), unit='km') # type: ignore[arg-type] # deliberately the wrong type m = Matrix3.zeros((2, 2), dtype='int') assert m.vals.dtype.kind == 'f' @@ -345,7 +344,7 @@ def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() m_write = Matrix3.x_rotation(np.pi/4).copy() with pytest.raises((ValueError, TypeError)): - (lambda: m_write.__imul__("invalid"))() + (lambda: m_write.__imul__("invalid"))() # type: ignore[arg-type] # deliberately the wrong type m_readonly = Matrix3.IDENTITY with pytest.raises(ValueError): @@ -371,6 +370,9 @@ def test_matrix3_test_basic_construction_arrays_of_wrong_shape_raise_valueerr() result_rmul = m2.__rmul__(m1, recursive=False) assert type(result_rmul) == Matrix3 + with pytest.raises(TypeError, match=r'Matrix3 "\*"'): + m2.__rmul__('abc') + v = Vector3([1., 0., 0.]) v.insert_deriv('t', Vector3([0., 1., 0.])) v_rotated_no_derivs = m2.rotate(v, recursive=False) diff --git a/tests/test_matrix3_deriv_class.py b/tests/test_matrix3_deriv_class.py index f0fb4e9..69cf484 100644 --- a/tests/test_matrix3_deriv_class.py +++ b/tests/test_matrix3_deriv_class.py @@ -2,6 +2,8 @@ # tests/test_matrix3_deriv_class.py: Tests of the class used for a Matrix3 derivative ########################################################################################## +from typing import cast + import numpy as np from polymath import Matrix, Matrix3, Qube, Scalar, Vector, Vector3 @@ -19,7 +21,7 @@ def _with_deriv(matrix: Matrix3, values: np.ndarray) -> Matrix3: obj = matrix.copy() obj.insert_deriv('t', Matrix(values)) - return obj + return cast(Matrix3, obj) def test_matrix3_deriv_class_is_matrix() -> None: diff --git a/tests/test_matrix3_pickle.py b/tests/test_matrix3_pickle.py index 54fcdec..1a334d8 100644 --- a/tests/test_matrix3_pickle.py +++ b/tests/test_matrix3_pickle.py @@ -2,11 +2,13 @@ # tests/test_matrix3_pickle.py: Tests of Matrix3.__getstate__ and __setstate__ ########################################################################################## +from typing import Any + import numpy as np import pickle import pytest -from polymath import Matrix, Matrix3, Quaternion +from polymath import Matrix, Matrix3, Quaternion, Qube def _rotations(shape: tuple[int, ...]) -> Matrix3: @@ -32,7 +34,7 @@ def _tangent(matrix: Matrix3, denom: tuple[int, ...] = ()) -> np.ndarray: return np.moveaxis(np.matmul(skew, values), (-2, -1), (-2 - drank, -1 - drank)) -def _uses_quaternion(matrix: Matrix3) -> bool: +def _uses_quaternion(matrix: Qube) -> bool: """True if this object pickles via the quaternion encoding.""" return 'QUATERNION_ENCODING' in matrix.__getstate__() @@ -117,7 +119,7 @@ def test_matrix3_pickle_round_trip_at_180_degrees() -> None: quaternion = Quaternion(np.zeros((500, 4))) quaternion.values[:, 1] = 1. # (0, 1, 0, 0): 180 degrees about x - matrix = quaternion.to_matrix3() + matrix: Any = quaternion.to_matrix3() assert _uses_quaternion(matrix) restored = pickle.loads(pickle.dumps(matrix)) @@ -185,7 +187,7 @@ def test_matrix3_pickle_round_trip_masked_derivative() -> None: @pytest.mark.parametrize('digits', ['double', 'single', 10, 7]) -def test_matrix3_pickle_honors_pickle_digits(digits: object) -> None: +def test_matrix3_pickle_honors_pickle_digits(digits: Any) -> None: """Every supported precision setting round-trips through the quaternion encoding.""" np.random.seed(8021) diff --git a/tests/test_matrix_solve.py b/tests/test_matrix_solve.py index 2fb6106..ceb1b80 100644 --- a/tests/test_matrix_solve.py +++ b/tests/test_matrix_solve.py @@ -79,7 +79,7 @@ def test_matrix_solve_propagates_the_mask_of_either_operand() -> None: a = Matrix([np.eye(2), np.eye(2)], mask=[True, False]) b = Vector([[1., 2.], [3., 4.]], mask=[False, True]) - assert list(a.solve(b).mask) == [True, True] + assert list(np.asarray(a.solve(b).mask)) == [True, True] def test_matrix_solve_with_nozeros_raises_on_a_singular_matrix() -> None: diff --git a/tests/test_pair.py b/tests/test_pair.py index e0c68eb..689c810 100644 --- a/tests/test_pair.py +++ b/tests/test_pair.py @@ -198,7 +198,7 @@ def test_pair_test_basic_construction() -> None: p23 = Pair.from_scalars(1., 2., readonly=True) assert type(p23) == Pair - # readonly may not be set by Qube.from_scalars, but parameter is accepted + assert p23.readonly p24 = Pair([1., 2.]) p24_swapped = p24.swapxy() diff --git a/tests/test_pair_misc.py b/tests/test_pair_misc.py index aed0c2b..64d6bb2 100755 --- a/tests/test_pair_misc.py +++ b/tests/test_pair_misc.py @@ -3,6 +3,8 @@ # Old Pair tests, updated by MRS 2/18/14 ########################################################################################## +from typing import Any + import numpy as np import pytest @@ -16,7 +18,7 @@ def test_pair_misc_basic_comparisons_and_indexing() -> None: assert pairs.numer == (2,) assert pairs.shape == (3,) assert pairs.rank == 1 - test = [[1,2],[3,4],[5,6]] + test: Any = [[1,2],[3,4],[5,6]] assert pairs == test test = Pair(test) assert pairs == test diff --git a/tests/test_polynomial_arithmetic.py b/tests/test_polynomial_arithmetic.py index ccd8358..8e0226f 100644 --- a/tests/test_polynomial_arithmetic.py +++ b/tests/test_polynomial_arithmetic.py @@ -177,7 +177,7 @@ def test_polynomial_arithmetic_test_neg() -> None: p_iadd1 += p_iadd2 assert id(p_iadd1) == id_before # In-place - assert len(p_iadd1.values) == 3 # Should have 3 coefficients + assert len(np.asarray(p_iadd1.values)) == 3 # Should have 3 coefficients p_iadd_deriv1 = Polynomial([1., 2.]) p_iadd_deriv2 = Polynomial([3., 4.]) @@ -189,7 +189,7 @@ def test_polynomial_arithmetic_test_neg() -> None: p_isub1 = Polynomial([5., 6.]) # order 1 p_isub2 = Polynomial([1., 2., 3.]) # order 2 p_isub1 -= p_isub2 - assert len(p_isub1.values) == 3 + assert len(np.asarray(p_isub1.values)) == 3 p_isub_self_larger = Polynomial([10., 20., 30., 40.]) # order 3 p_isub_arg_smaller = Polynomial([1., 2.]) # order 1 @@ -200,7 +200,7 @@ def test_polynomial_arithmetic_test_neg() -> None: p_isub3 = Polynomial([5., 6., 7.]) # order 2 p_isub4 = Polynomial([1., 2.]) # order 1, needs at_least_order p_isub3 -= p_isub4 - assert len(p_isub3.values) == 3 + assert len(np.asarray(p_isub3.values)) == 3 p_isub_deriv1 = Polynomial([5., 6.]) p_isub_deriv2 = Polynomial([1., 2.]) diff --git a/tests/test_quaternion.py b/tests/test_quaternion.py index 3319659..f9cafd1 100755 --- a/tests/test_quaternion.py +++ b/tests/test_quaternion.py @@ -10,10 +10,10 @@ import numpy as np import pytest -from polymath import Matrix, Matrix3, Quaternion, Scalar, Vector, Vector3 +from polymath import Matrix, Matrix3, Quaternion, Qube, Scalar, Vector, Vector3 -def assert_rms_less_than(diff, threshold): +def assert_rms_less_than(diff: Qube, threshold: float) -> None: """Helper method to assert RMS value is less than threshold, handling masked Scalars.""" rms_val = diff.rms() # Extract numeric value if rms returns a Scalar diff --git a/tests/test_quaternion_matrix3.py b/tests/test_quaternion_matrix3.py index 0538039..708801d 100755 --- a/tests/test_quaternion_matrix3.py +++ b/tests/test_quaternion_matrix3.py @@ -171,7 +171,8 @@ def test_quaternion_matrix3_from_matrix3_derivative_round_trip() -> None: # The derivative of a rotation is orthogonal to the quaternion; a parallel # component leaves the matrix unchanged and so cannot be recovered da_dt = np.random.randn(N,4) - da_dt -= np.sum(da_dt * a.values, axis=-1)[:,np.newaxis] * a.values + a_values = np.asarray(a.values) + da_dt -= np.sum(da_dt * a_values, axis=-1)[:,np.newaxis] * a_values a.insert_deriv('t', Quaternion(da_dt)) b = Quaternion.from_matrix3(a.to_matrix3(recursive=True)) diff --git a/tests/test_qube_add_attr.py b/tests/test_qube_add_attr.py index 999f372..3a820ca 100644 --- a/tests/test_qube_add_attr.py +++ b/tests/test_qube_add_attr.py @@ -81,7 +81,7 @@ def test_qube_add_attr_allows_direct_assignment_afterward() -> None: a = Scalar(np.random.randn(5)) a.add_attr('label', 'north') - a.label = 'south' # type: ignore[attr-defined] # add_attr() created it above + a.label = 'south' assert attr(a.clone(), 'label') == 'south' diff --git a/tests/test_qube_all.py b/tests/test_qube_all.py index 0a495d8..ecdc3b0 100755 --- a/tests/test_qube_all.py +++ b/tests/test_qube_all.py @@ -2,6 +2,8 @@ # tests/test_qube_all.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_qube_any.py b/tests/test_qube_any.py index a6e639d..7638069 100755 --- a/tests/test_qube_any.py +++ b/tests/test_qube_any.py @@ -2,6 +2,8 @@ # tests/test_qube_any.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_qube_cast.py b/tests/test_qube_cast.py index b9c8a69..1f73ca1 100644 --- a/tests/test_qube_cast.py +++ b/tests/test_qube_cast.py @@ -15,7 +15,7 @@ def test_qube_cast_to_the_same_class_returns_the_object() -> None: a = Vector3(np.random.randn(5, 3)) - assert a.cast(Vector3) is a + assert a.cast(classes=Vector3) is a def test_qube_cast_to_an_incompatible_class_returns_the_object() -> None: @@ -25,7 +25,7 @@ def test_qube_cast_to_an_incompatible_class_returns_the_object() -> None: a = Vector3(np.random.randn(5, 3)) - assert a.cast(Matrix3) is a + assert a.cast(classes=Matrix3) is a def test_qube_cast_selects_the_first_suitable_class() -> None: @@ -34,7 +34,7 @@ def test_qube_cast_selects_the_first_suitable_class() -> None: np.random.seed(6011) a = Vector(np.random.randn(5, 3)) - b = a.cast((Matrix3, Vector3, Vector)) + b = a.cast(classes=(Matrix3, Vector3, Vector)) assert type(b) is Vector3 @@ -47,7 +47,7 @@ def test_qube_cast_preserves_the_values_and_the_mask() -> None: values = np.random.randn(5, 3) mask = np.array([True, False, False, True, False]) a = Vector(values, mask) - b = a.cast(Vector3) + b = a.cast(classes=Vector3) assert np.all(b.values == values) assert np.all(b.mask == mask) @@ -61,7 +61,7 @@ def test_qube_cast_preserves_the_unit() -> None: np.random.seed(6011) a = Vector(np.random.randn(5, 3), unit=Unit.KM) - b = a.cast(Vector3) + b = a.cast(classes=Vector3) assert b.unit_ == Unit.KM @@ -74,7 +74,7 @@ def test_qube_cast_preserves_the_derivatives() -> None: deriv = np.random.randn(5, 3) a = Vector(np.random.randn(5, 3)) a.insert_deriv('t', Vector(deriv)) - b = a.cast(Vector3) + b = a.cast(classes=Vector3) assert ('t' in b.derivs) assert np.all(b.d_dt.values == deriv) @@ -87,7 +87,7 @@ def test_qube_cast_preserves_readonly_status() -> None: a = Vector(np.random.randn(5, 3)).as_readonly() - assert a.cast(Vector3).readonly + assert a.cast(classes=Vector3).readonly def test_qube_cast_of_a_writable_object_is_writable() -> None: @@ -97,14 +97,14 @@ def test_qube_cast_of_a_writable_object_is_writable() -> None: a = Vector(np.random.randn(5, 3)) - assert not a.cast(Vector3).readonly + assert not a.cast(classes=Vector3).readonly def test_qube_cast_coerces_an_integer_object_to_a_float_class() -> None: """A class that disallows integers receives the values coerced to floats.""" a = Vector(np.arange(6).reshape(2, 3)) - b = a.cast(Vector3) + b = a.cast(classes=Vector3) assert type(b) is Vector3 assert b.is_float() @@ -118,7 +118,7 @@ def test_qube_cast_to_a_class_without_derivatives_is_rejected() -> None: a.insert_deriv('t', Scalar([3., 4.])) with pytest.raises(ValueError, match='derivatives are disallowed'): - a.cast(Boolean) + a.cast(classes=Boolean) def test_qube_cast_does_not_alter_the_source() -> None: @@ -128,7 +128,7 @@ def test_qube_cast_does_not_alter_the_source() -> None: a = Vector(np.random.randn(5, 3)) a.insert_deriv('t', Vector(np.random.randn(5, 3))) - a.cast(Vector3) + a.cast(classes=Vector3) assert type(a) is Vector assert ('t' in a.derivs) @@ -141,7 +141,7 @@ def test_qube_cast_of_a_rank_zero_object_to_scalar() -> None: values = np.random.randn(5) a = Qube._new_from_parts(values, False, nrank=0) - b = a.cast(Scalar) + b = a.cast(classes=Scalar) assert type(b) is Scalar assert np.all(b.values == values) diff --git a/tests/test_qube_coverage.py b/tests/test_qube_coverage.py index f2cc272..67273f6 100644 --- a/tests/test_qube_coverage.py +++ b/tests/test_qube_coverage.py @@ -21,7 +21,7 @@ def test_qube_coverage_test_example_not_a_qube() -> None: np.random.seed(98765) with pytest.raises(TypeError): - _ = Scalar(1., example="not a qube") + _ = Scalar(1., example="not a qube") # type: ignore[arg-type] # deliberately the wrong type # Test derivatives disallowed # Need a class that disallows derivatives @@ -138,7 +138,7 @@ def test_qube_coverage_test_example_not_a_qube() -> None: try: a = Scalar([1., 2., 3.]) - a.insert_deriv('t', "not a qube") + a.insert_deriv('t', "not a qube") # type: ignore[arg-type] # wrong type on purpose except TypeError: pass # Expected @@ -426,7 +426,7 @@ def test_qube_coverage_test_example_not_a_qube() -> None: a = Scalar([1., 2., 3.]) a.insert_deriv('t', Scalar([0.1, 0.2, 0.3])) - name = a.unique_deriv_name('t', object()) # object has no derivs + name = a.unique_deriv_name('t', object()) # type: ignore[arg-type] # no derivs assert name != 't' @@ -560,13 +560,13 @@ def test_qube_coverage_test_example_not_a_qube() -> None: a = Scalar([1., 2., 3.]) - b = a.cast([Vector]) + b = a.cast(classes=[Vector]) assert a is b # Should return self when no suitable class - b = a.cast([Scalar]) + b = a.cast(classes=[Scalar]) assert a is b - b = a.cast(Scalar) + b = a.cast(classes=Scalar) assert a is b # Test incompatible _NUMER @@ -1079,7 +1079,7 @@ class NoUnitsQube(Qube): assert a is not b - assert b.readonly + assert not b.readonly a = Scalar([1., 2., 3.]) readonly_mask = np.array([False, True, False]) @@ -1567,10 +1567,10 @@ def test_qube_or_with_three_or_more_masks() -> None: b = np.array([False, True, False]) assert Qube.or_(a, b, False) is not True - assert list(Qube.or_(a, b, False)) == [True, True, False] + assert list(np.asarray(Qube.or_(a, b, False))) == [True, True, False] assert Qube.or_(a, b, True) is True assert Qube.or_(False, False, False) is False - assert list(Qube.or_(a, a, a)) == [True, False, False] + assert list(np.asarray(Qube.or_(a, a, a))) == [True, False, False] def test_qube_and_with_three_or_more_masks() -> None: @@ -1579,10 +1579,10 @@ def test_qube_and_with_three_or_more_masks() -> None: a = np.array([True, True, False]) b = np.array([True, False, True]) - assert list(Qube.and_(a, b, True)) == [True, False, False] + assert list(np.asarray(Qube.and_(a, b, True))) == [True, False, False] assert Qube.and_(a, b, False) is False assert Qube.and_(True, True, True) is True - assert list(Qube.and_(a, a, a)) == [True, True, False] + assert list(np.asarray(Qube.and_(a, a, a))) == [True, True, False] def test_qube_an_explicit_numerator_rank_of_zero_is_honored() -> None: diff --git a/tests/test_qube_ext_item_ops.py b/tests/test_qube_ext_item_ops.py index 01d5dfe..44d8040 100644 --- a/tests/test_qube_ext_item_ops.py +++ b/tests/test_qube_ext_item_ops.py @@ -393,7 +393,7 @@ def test_qube_ext_item_ops_simple_case_join_1_d_denominator_to_numerator() -> No a = Vector(np.arange(9).reshape(3, 3), drank=1) # shape (), numer (3,), denom (3,) assert a.numer == (3,) assert a.denom == (3,) - b = a.join_items(Matrix) + b = a.join_items(classes=Matrix) assert b.shape == () # Shape is preserved assert b.numer == (3, 3) # numer and denom are joined assert b.denom == () @@ -410,7 +410,7 @@ def test_qube_ext_item_ops_complex_n_d_case_join_with_shape_for_shape_2_numer_3_ ################################################################################## a = Vector(np.arange(12).reshape(2, 3, 2), drank=1) # shape (2,), numer (3,), denom (2,) - b = a.join_items(Matrix) + b = a.join_items(classes=Matrix) assert b.shape == (2,) # Shape is preserved assert b.numer == (3, 2) # numer and denom are joined assert b.denom == () @@ -426,7 +426,7 @@ def test_qube_ext_item_ops_test_with_classes_parameter_list() -> None: ################################################################################## a = Vector(np.arange(9).reshape(3, 3), drank=1) - b = a.join_items((Boolean, Scalar, Matrix3, Matrix)) + b = a.join_items(classes=(Boolean, Scalar, Matrix3, Matrix)) assert type(b) == Matrix3 @@ -441,7 +441,7 @@ def test_qube_ext_item_ops_test_with_drank_0_should_return_without_derivatives() ################################################################################## a = Vector([1., 2., 3.]) - b = a.join_items(Matrix) + b = a.join_items(classes=Matrix) assert a.wod == b # Should return without derivatives ################################################################################## @@ -459,7 +459,7 @@ def test_qube_ext_item_ops_simple_case_split_numerator_to_denominator_use_matrix ################################################################################## a = Matrix(np.arange(24).reshape(2, 3, 4)) # shape (2,), numer (3, 4), denom () - b = a.split_items(1, Matrix) # Keep first 1 numer axis, rest become denom + b = a.split_items(1, classes=Matrix) # Keep first 1 numer axis, rest become denom assert b.shape == (2,) assert b.numer == (3,) # First numer axis assert b.denom == (4,) # Remaining becomes denom @@ -477,7 +477,7 @@ def test_qube_ext_item_ops_complex_n_d_case_split_with_shape_use_matrix_which_ha ################################################################################## a = Matrix(np.arange(24).reshape(2, 3, 4)) # shape (2,), numer (3, 4) - b = a.split_items(1, Vector) # Keep first 1 numer axis, rest become denom + b = a.split_items(1, classes=Vector) # Keep first 1 numer axis, rest become denom assert b.shape == (2,) assert b.numer == (3,) # First numer axis assert b.denom == (4,) # Remaining becomes denom @@ -493,7 +493,7 @@ def test_qube_ext_item_ops_test_with_classes_parameter_use_matrix_which_has_nran ################################################################################## a = Matrix(np.arange(24).reshape(2, 3, 4)) # shape (2,), numer (3, 4) - b = a.split_items(1, (Boolean, Scalar, Vector3, Vector)) + b = a.split_items(1, classes=(Boolean, Scalar, Vector3, Vector)) assert isinstance(b, Qube) @@ -514,7 +514,7 @@ def test_qube_ext_item_ops_simple_case_swap_numerator_and_denominator() -> None: a = Vector(np.arange(9).reshape(3, 3), drank=1) # shape (), numer (3,), denom (3,) assert a.numer == (3,) assert a.denom == (3,) - b = a.swap_items(Matrix) + b = a.swap_items(classes=Matrix) assert b.shape == () # Shape is preserved assert b.numer == (3,) # Swapped from denom assert b.denom == (3,) # Swapped from numer @@ -532,7 +532,7 @@ def test_qube_ext_item_ops_complex_n_d_case_swap_with_different_sizes() -> None: ################################################################################## a = Vector(np.arange(24).reshape(2, 3, 4), drank=1) # shape (2,), numer (3,), denom (4,) - b = a.swap_items(Matrix) + b = a.swap_items(classes=Matrix) assert b.shape == (2,) assert b.numer == (4,) # Swapped from denom assert b.denom == (3,) # Swapped from numer @@ -548,7 +548,7 @@ def test_qube_ext_item_ops_test_with_classes_parameter() -> None: ################################################################################## a = Vector(np.arange(9).reshape(3, 3), drank=1) - b = a.swap_items((Boolean, Scalar, Matrix3, Matrix)) + b = a.swap_items(classes=(Boolean, Scalar, Matrix3, Matrix)) assert isinstance(b, Qube) diff --git a/tests/test_qube_ext_math_ops.py b/tests/test_qube_ext_math_ops.py index 91e49d2..2502baf 100644 --- a/tests/test_qube_ext_math_ops.py +++ b/tests/test_qube_ext_math_ops.py @@ -706,7 +706,7 @@ def test_qube_floordiv_by_a_number_matches_division_by_a_scalar() -> None: a = Scalar([7.5, -3.5, 0.5]) assert a // 2 == a // Scalar(2) - assert list((a // 2).values) == [3., -2., 0.] + assert list(np.asarray((a // 2).values)) == [3., -2., 0.] def test_qube_floordiv_by_zero_masks_everything() -> None: @@ -740,8 +740,8 @@ def test_qube_ipow_raises_this_object_in_place() -> None: a **= 3 assert a is before - assert list(a.values) == [1., 8., 27.] - assert list(a.derivs['t'].values) == [3., 12., 27.] + assert list(np.asarray(a.values)) == [1., 8., 27.] + assert list(np.asarray(a.derivs['t'].values)) == [3., 12., 27.] def test_qube_ipow_updates_the_unit() -> None: @@ -759,8 +759,8 @@ def test_qube_ipow_of_an_exponent_of_one_keeps_the_derivatives() -> None: a = Scalar([1., 2.]) a.insert_deriv('t', Scalar([5., 6.])) a **= 1 - assert list(a.values) == [1., 2.] - assert list(a.derivs['t'].values) == [5., 6.] + assert list(np.asarray(a.values)) == [1., 2.] + assert list(np.asarray(a.derivs['t'].values)) == [5., 6.] def test_qube_ipow_rejects_a_non_integer_result_for_an_integer_object() -> None: @@ -770,7 +770,7 @@ def test_qube_ipow_rejects_a_non_integer_result_for_an_integer_object() -> None: with pytest.raises(TypeError, match='non-integer result'): a **= -1 - assert list(a.values) == [2, 3] # unchanged by the failed operation + assert list(np.asarray(a.values)) == [2, 3] # unchanged by the failed operation def test_qube_ipow_rejects_a_read_only_object() -> None: diff --git a/tests/test_qube_ext_pickler.py b/tests/test_qube_ext_pickler.py index 265da13..717bfc0 100644 --- a/tests/test_qube_ext_pickler.py +++ b/tests/test_qube_ext_pickler.py @@ -6,7 +6,7 @@ import numpy as np import pytest import pickle -from typing import Any +from typing import Any, cast from polymath import Qube, Scalar, Vector, Vector3, Boolean @@ -1271,7 +1271,7 @@ def _restored(state: dict[str, Any]) -> Scalar: obj = Qube.__new__(Scalar) obj.__setstate__(state) - return obj + return cast(Scalar, obj) @pytest.mark.parametrize(('values', 'is_array'), [(np.arange(5.), True), (1.5, False)]) diff --git a/tests/test_qube_ext_tvl.py b/tests/test_qube_ext_tvl.py index e48616f..6822a9f 100644 --- a/tests/test_qube_ext_tvl.py +++ b/tests/test_qube_ext_tvl.py @@ -2,6 +2,8 @@ # tests/test_qube_ext_tvl.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest import numpy.ma as ma @@ -10,7 +12,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(False) yield diff --git a/tests/test_qube_ext_vector_ops.py b/tests/test_qube_ext_vector_ops.py index dd7a1dd..b963d21 100644 --- a/tests/test_qube_ext_vector_ops.py +++ b/tests/test_qube_ext_vector_ops.py @@ -3,6 +3,8 @@ # Unit tests for Qube vector operations ########################################################################################## +from typing import cast + import numpy as np import pytest @@ -716,7 +718,8 @@ def test_qube_ext_vector_ops_test_limit_from_qube_line_465_when_limit_numer_is_t # This might be defensive code for future types -def _reference_dot(arg1, arg2, axis1=-1, axis2=0): +def _reference_dot(arg1: Qube, arg2: Qube, axis1: int = -1, + axis2: int = 0) -> np.ndarray: """The dot product computed by broadcasting the numerator axes and contracting.""" a1 = axis1 if axis1 >= 0 else axis1 + arg1._nrank @@ -730,8 +733,9 @@ def _reference_dot(arg1, arg2, axis1=-1, axis2=0): array2 = arg2._values.reshape(arg2._shape + (arg1._nrank - 1) * (1,) + arg2._numer + arg1._drank * (1,) + arg2._denom) - return np.einsum('...i,...i->...', np.moveaxis(array1, k1, -1), - np.moveaxis(array2, k2, -1)) + return cast(np.ndarray, np.einsum('...i,...i->...', + np.moveaxis(array1, k1, -1), + np.moveaxis(array2, k2, -1))) def test_qube_ext_vector_ops_dot_of_two_matrices() -> None: diff --git a/tests/test_qube_getstate.py b/tests/test_qube_getstate.py index 2d862d9..c3903ba 100644 --- a/tests/test_qube_getstate.py +++ b/tests/test_qube_getstate.py @@ -2,6 +2,8 @@ # test_qube_getstate.py: Tests of __getstate__ and __setstate__ ########################################################################################## +from typing import Any + import numpy as np import pickle import os @@ -261,7 +263,8 @@ def test_qube_getstate_scalar_tests_derivatives_units(pickle_debug: object) -> N # COMPRESSION - references = (1., 'smallest', 'largest', 'mean', 'median', 'logmean') + references: tuple[Any, ...] = (1., 'smallest', 'largest', 'mean', 'median', + 'logmean') ref_values = [1., np.min(np.abs(a.values)), np.max(np.abs(a.values)), diff --git a/tests/test_qube_identity.py b/tests/test_qube_identity.py index 6cbcd28..1f84580 100755 --- a/tests/test_qube_identity.py +++ b/tests/test_qube_identity.py @@ -2,6 +2,8 @@ # tests/test_qube._dentity.py ########################################################################################## +from typing import Any + import numpy as np import pytest @@ -11,7 +13,7 @@ def test_qube_identity() -> None: """Exercise qube identity.""" - a = Scalar((1,2,3)) + a: Any = Scalar((1,2,3)) assert a.identity() == 1 assert type(a.identity()) == Scalar assert type(a.identity().values) == int diff --git a/tests/test_qube_items.py b/tests/test_qube_items.py index 113a189..171069d 100755 --- a/tests/test_qube_items.py +++ b/tests/test_qube_items.py @@ -249,16 +249,16 @@ def test_qube_items() -> None: # join_items(self, classes) ################################################################################## a = Vector(np.random.randn(5,4,3,2), drank=1) - b = a.join_items(Matrix) + b = a.join_items(classes=Matrix) assert b.shape == (5,4) assert b.numer == (3,2) assert b.denom == () - b = a.join_items((Boolean,Scalar,Matrix3,Quaternion,Matrix)) + b = a.join_items(classes=(Boolean,Scalar,Matrix3,Quaternion,Matrix)) assert type(b) == Matrix assert a.readonly == False assert b.readonly == False a = a.as_readonly() - b = a.join_items(Matrix) + b = a.join_items(classes=Matrix) assert a.readonly == True assert b.readonly == True @@ -266,7 +266,7 @@ def test_qube_items() -> None: # swap_items(self, classes) ################################################################################## a = Vector(np.random.randn(5,4,3,2), drank=2) - b = a.swap_items((Boolean,Scalar,Matrix3,Quaternion,Matrix)) + b = a.swap_items(classes=(Boolean,Scalar,Matrix3,Quaternion,Matrix)) assert type(b) == Matrix assert b.shape == a.shape assert b.numer == a.denom @@ -278,7 +278,7 @@ def test_qube_items() -> None: assert a.readonly == False assert b.readonly == False a = a.as_readonly() - b = a.swap_items(Matrix) + b = a.swap_items(classes=Matrix) assert a.readonly == True assert b.readonly == True diff --git a/tests/test_qube_masking.py b/tests/test_qube_masking.py index 5dd07f3..c68c110 100755 --- a/tests/test_qube_masking.py +++ b/tests/test_qube_masking.py @@ -164,4 +164,28 @@ def test_qube_masking() -> None: assert Boolean(a.clip([7,6,5,4,3,2],upper,remask=False).mask) == False +def test_remask_or_ors_derivative_masks() -> None: + """remask_or() or-s the mask into each derivative rather than replacing it.""" + + a = Scalar([1., 2., 3.]) + a.insert_deriv('t', Scalar([4., 5., 6.])) + a = a.mask_where_eq(2.) + assert a.d_dt.mask[1] + b = a.remask_or([False, False, True]) + assert np.array_equal(b.mask, [False, True, True]) + assert np.array_equal(b.d_dt.mask, [False, True, True]) + + +def test_as_one_masked_recursive() -> None: + """as_one_masked() keeps derivatives only when recursive is True.""" + + a = Scalar([1., 2., 3.]) + a.insert_deriv('t', Scalar([4., 5., 6.])) + b = a.as_one_masked() + assert b.shape == () + assert b.mask + assert b.d_dt.mask + c = a.as_one_masked(recursive=False) + assert c.derivs == {} + ########################################################################################## diff --git a/tests/test_qube_new_from_parts.py b/tests/test_qube_new_from_parts.py index c66d11a..f4b9280 100644 --- a/tests/test_qube_new_from_parts.py +++ b/tests/test_qube_new_from_parts.py @@ -2,13 +2,15 @@ # tests/test_qube_new_from_parts.py ########################################################################################## +from typing import Any + import numpy as np import pytest from polymath import Matrix, Qube, Scalar, Unit, Vector, Vector3 -def _attrs(obj: Qube) -> dict: +def _attrs(obj: Qube) -> dict[str, Any]: """Every shape and type attribute that the two constructors both determine.""" return {name: getattr(obj, name) @@ -27,7 +29,7 @@ def _attrs(obj: Qube) -> dict: ((), 2, 0), ((4,), 2, 1), ]) -def test_qube_new_from_parts_matches_the_constructor(shape: tuple, nrank: int, +def test_qube_new_from_parts_matches_the_constructor(shape: tuple[int, ...], nrank: int, drank: int) -> None: """The fast constructor derives the same shape attributes as __init__().""" diff --git a/tests/test_qube_readonly.py b/tests/test_qube_readonly.py index e5a79d1..1ce5a3c 100755 --- a/tests/test_qube_readonly.py +++ b/tests/test_qube_readonly.py @@ -68,4 +68,45 @@ def test_qube_readonly() -> None: assert (a.d_dm.values[0,0,0,0] != 42) +def test_qube_readonly_copy_of_a_readonly_object_keeps_the_derivatives() -> None: + """A read-only copy of a read-only object retains its derivatives when recursive.""" + + np.random.seed(1729) + + a = Vector(np.random.randn(5, 3)) + a.insert_deriv('m', Vector(np.random.randn(5, 3, 2), drank=1)) + a = a.as_readonly() + + b = a.copy(readonly=True, recursive=True) + assert 'm' in b.derivs + assert b.d_dm.readonly + assert b.readonly + + +def test_qube_readonly_copy_of_a_readonly_object_can_drop_the_derivatives() -> None: + """A read-only copy of a read-only object omits its derivatives when not recursive.""" + + np.random.seed(1730) + + a = Vector(np.random.randn(5, 3)) + a.insert_deriv('m', Vector(np.random.randn(5, 3, 2), drank=1)) + a = a.as_readonly() + + b = a.copy(readonly=True, recursive=False) + assert not b.derivs + assert b.readonly + + +def test_qube_readonly_copy_of_a_readonly_object_shares_its_array() -> None: + """A read-only copy of a read-only object is shallow, sharing the original array.""" + + np.random.seed(1731) + + a = Scalar(np.random.randn(5)).as_readonly() + b = a.copy(readonly=True) + + assert b.values is a.values + assert b is not a + + ########################################################################################## diff --git a/tests/test_qube_reshaping.py b/tests/test_qube_reshaping.py index 5ce86d6..6d090de 100755 --- a/tests/test_qube_reshaping.py +++ b/tests/test_qube_reshaping.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from polymath import Pair, Qube, Matrix, Scalar, Vector, Vector3 +from polymath import Boolean, Pair, Qube, Matrix, Scalar, Unit, Vector, Vector3 def test_qube_reshaping_reshape_self_shape_recursive_true() -> None: @@ -626,7 +626,6 @@ def test_qube_reshaping_stack_with_units() -> None: np.random.seed(2292) - from polymath.unit import Unit a = Scalar([1., 2., 3.], unit=Unit.KM) b = Scalar([4., 5., 6.], unit=Unit.KM) c = Qube.stack(a, b) @@ -694,7 +693,6 @@ def test_qube_reshaping_test_stack_with_bool_arg_logic_bool_arg_is_none_or_not_q np.random.seed(2292) - from polymath.boolean import Boolean a = Boolean([True, False, True]) b = Boolean([False, True, False]) c = Qube.stack(a, b) diff --git a/tests/test_qube_setitem.py b/tests/test_qube_setitem.py index 599dc13..5c6dc21 100755 --- a/tests/test_qube_setitem.py +++ b/tests/test_qube_setitem.py @@ -114,7 +114,7 @@ def test_qube_setitem() -> None: a[0] = b[0] assert np.all(a.values[0] == b.values[0]) assert np.all(a.mask[0] == True) - assert type(a.mask) == np.ndarray + assert isinstance(a.mask, np.ndarray) assert type(b.mask) == bool a[:,0] = b[:,0] assert np.all(a.values[:,0] == b.values[:,0]) diff --git a/tests/test_qube_shrink.py b/tests/test_qube_shrink.py index ba47605..b327d37 100755 --- a/tests/test_qube_shrink.py +++ b/tests/test_qube_shrink.py @@ -2,6 +2,8 @@ # tests/test_qube_shrink.py ########################################################################################## +from typing import Any + import numpy as np from polymath import Qube, Scalar, Vector3, Boolean @@ -97,7 +99,7 @@ def test_qube_shrink_antimask() -> None: assert a[a.antimask] == a a = Scalar(values, True) assert np.all(a.mask ^ a.antimask) - assert a[a.antimask].shape == (np.sum(a.antimask),200) + assert a[a.antimask].shape == (int(np.sum(a.antimask)),200) assert a[np.newaxis][:0].shape == (0,100,200) values = np.ones((100,200,3)) mask = np.zeros((100,200), dtype='bool') @@ -110,7 +112,7 @@ def test_qube_shrink_antimask() -> None: assert a[a.antimask] == a a = Vector3(values, True) assert np.all(a.mask ^ a.antimask) - assert a[a.antimask].shape == (np.sum(a.antimask),200) + assert a[a.antimask].shape == (int(np.sum(a.antimask)),200) def test_qube_shrink_test_unshrink_with_and_without_ignore_unshrunk_as_cached() -> None: @@ -313,11 +315,13 @@ def test_qube_shrink_test_unshrink_with_and_without_ignore_unshrunk_as_cached() c = Scalar(np.random.randn(3,1,100), mask=np.random.randn(3,1,100) > 1.) d = Vector3(np.random.randn(100,3), mask=np.random.randn(100) > 1.) - for value in [1., Scalar(np.random.randn(2,100))]: - for mask in [True, False, - np.ones((2,100), dtype='bool'), - np.zeros((2,100), dtype='bool'), - np.random.randn(2,100) > 1.]: + samples: list[Any] = [1., Scalar(np.random.randn(2,100))] + masks: list[Any] = [True, False, + np.ones((2,100), dtype='bool'), + np.zeros((2,100), dtype='bool'), + np.random.randn(2,100) > 1.] + for value in samples: + for mask in masks: if np.shape(value) == () and np.shape(mask) != (): continue @@ -345,7 +349,7 @@ def test_qube_shrink_test_unshrink_with_and_without_ignore_unshrunk_as_cached() else: assert test1.all() - if np.shape(antimask) == (): + if not np.shape(antimask): test_mask = antimask else: pad = len(value1.shape) - len(np.shape(antimask)) @@ -370,7 +374,7 @@ def test_qube_shrink_qube_antimask() -> None: np.random.seed(1207) - values = [ + cases: list[tuple[Any, Any]] = [ (Boolean(True, False), False), (Boolean(True, True ), False), (Scalar([1,2], False), False), @@ -381,7 +385,7 @@ def test_qube_shrink_qube_antimask() -> None: (Scalar([1.,2.], np.array([False, True])), np.array([False, True])), (Scalar(np.arange(100), False), False), ] - for (a, antimask) in values: + for (a, antimask) in cases: aa = a.shrink(antimask) assert aa.shape == () b = aa.unshrink(antimask, a.shape) diff --git a/tests/test_qube_types.py b/tests/test_qube_types.py index 132fefc..29fb046 100755 --- a/tests/test_qube_types.py +++ b/tests/test_qube_types.py @@ -182,3 +182,34 @@ def test_qube_types() -> None: ########################################################################################## + + +def test_as_bool_converts_scalar_to_boolean() -> None: + """A Scalar converts to a Boolean, with zero False and everything else True.""" + + b = Scalar([0, 1, -2]).as_bool() + assert type(b) == Boolean + assert np.all(b.values == np.array([False, True, True])) + + b = Scalar([0., 2.5]).as_bool() + assert type(b) == Boolean + assert np.all(b.values == np.array([False, True])) + + b = Scalar(3).as_bool() + assert type(b) == Boolean + assert b.values == True + + +def test_as_bool_preserves_mask() -> None: + """The mask of the source carries over to the converted Boolean.""" + + b = Scalar([0, 1, 2], mask=[False, True, False]).as_bool() + assert np.all(b.mask == np.array([False, True, False])) + assert np.all(b.antimask == np.array([True, False, True])) + + +def test_as_bool_rejects_class_without_bools() -> None: + """A class that cannot hold truth values raises the documented TypeError.""" + + with pytest.raises(TypeError, match='Vector3 object cannot contain bools'): + Vector3([1., 2., 3.]).as_bool() diff --git a/tests/test_scalar_comprehensive.py b/tests/test_scalar_comprehensive.py index 204c2d9..b669e0c 100644 --- a/tests/test_scalar_comprehensive.py +++ b/tests/test_scalar_comprehensive.py @@ -793,7 +793,7 @@ def test_scalar_comprehensive_test_sort_preserves_the_mask() -> None: s = Scalar([3., 9., 1.], mask=[False, False, True]) result = s.sort() - assert list(result.mask) == [False, False, True] + assert list(np.asarray(result.mask)) == [False, False, True] assert result.vals[0] == 3. assert result.vals[1] == 9. @@ -803,7 +803,7 @@ def test_scalar_comprehensive_test_sort_mask_when_a_value_matches_the_fill() -> s = Scalar([3., np.inf, 1.], mask=[False, False, True]) result = s.sort() - assert list(result.mask) == [False, False, True] + assert list(np.asarray(result.mask)) == [False, False, True] assert result.vals[0] == 3. assert result.vals[1] == np.inf diff --git a/tests/test_scalar_coverage.py b/tests/test_scalar_coverage.py index 1b6de88..fd6d726 100644 --- a/tests/test_scalar_coverage.py +++ b/tests/test_scalar_coverage.py @@ -3,16 +3,18 @@ # Comprehensive coverage tests for scalar.py to achieve >90% coverage ########################################################################################## -import numpy as np -import pytest import warnings +from collections.abc import Iterator from contextlib import contextmanager +import numpy as np +import pytest + from polymath import Scalar, Vector, Boolean, Qube, Unit @contextmanager -def prefer_builtins(value): +def prefer_builtins(value: bool) -> Iterator[None]: """Context manager to temporarily set Qube.prefer_builtins() flag.""" old_value = Qube.prefer_builtins() try: @@ -81,12 +83,12 @@ def test_scalar_coverage_test_invalid_dtype() -> None: a = Scalar([1, 2, 3], mask=True) idx, mask = a.as_index_and_mask(purge=True) - assert len(idx) == 0 + assert len(np.asarray(idx)) == 0 a = Scalar([1, 2, 3]) a = a.mask_where_eq(2) idx, mask = a.as_index_and_mask(purge=True) - assert len(idx) == 2 + assert len(np.asarray(idx)) == 2 a = Scalar([1, 2, 3], mask=True) idx, mask = a.as_index_and_mask(masked=999) diff --git a/tests/test_scalar_max.py b/tests/test_scalar_max.py index 59b4ba3..29e080e 100755 --- a/tests/test_scalar_max.py +++ b/tests/test_scalar_max.py @@ -2,6 +2,8 @@ # tests/test_scalar_max.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_scalar_mean.py b/tests/test_scalar_mean.py index 229e89e..bc962b9 100755 --- a/tests/test_scalar_mean.py +++ b/tests/test_scalar_mean.py @@ -2,6 +2,8 @@ # tests/test_scalar_mean.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_scalar_median.py b/tests/test_scalar_median.py index 59d9c3e..a375992 100755 --- a/tests/test_scalar_median.py +++ b/tests/test_scalar_median.py @@ -2,6 +2,8 @@ # tests/test_scalar_median.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_scalar_min.py b/tests/test_scalar_min.py index 604b529..b7244f2 100755 --- a/tests/test_scalar_min.py +++ b/tests/test_scalar_min.py @@ -2,6 +2,8 @@ # tests/test_scalar_min.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_scalar_ops.py b/tests/test_scalar_ops.py index 871a9e6..c4d5740 100755 --- a/tests/test_scalar_ops.py +++ b/tests/test_scalar_ops.py @@ -307,7 +307,7 @@ def test_scalar_ops_unary_plus() -> None: assert b.d_dt == 2 assert not a.readonly assert not b.readonly - assert not a.d_dt.readonly # writeable because it is a scalar + assert not a.d_dt.readonly # writable because it is a scalar assert b.d_dt.readonly # readonly because of broadcast a = Scalar(1, derivs={'t':Scalar(2)}) b = (1,2,3) + a @@ -1615,18 +1615,32 @@ def test_scalar_ops_reciprocal_disallows_denominators() -> None: a.reciprocal() -@pytest.mark.parametrize(('symbol', 'func'), - [('<' , operator.lt), - ('<=', operator.le), - ('>' , operator.gt), - ('>=', operator.ge)]) -def test_scalar_ops_comparisons_disallow_denominators( - symbol: str, func: Callable[[Scalar, Scalar], object]) -> None: - """The ordering comparisons do not support denominators.""" +@pytest.mark.parametrize(('func', 'symbol'), + [(operator.lt, '<'), (operator.le, '<='), + (operator.gt, '>'), (operator.ge, '>=')], + ids=['lt', 'le', 'gt', 'ge']) +def test_scalar_ops_comparisons_disallow_a_denominator_on_the_left( + func: Callable[[Scalar, Scalar], Boolean], symbol: str) -> None: + """The ordering comparisons reject a denominator in the left operand.""" a = Scalar([[1., 2.], [3., 4.]], drank=1) - with pytest.raises(ValueError, match=f'"{symbol}" does not support denominators'): - func(a, a) + b = Scalar([1., 2.]) + with pytest.raises(ValueError, match=f'Scalar "{symbol}" does not support denom'): + func(a, b) + + +@pytest.mark.parametrize(('func', 'symbol'), + [(operator.lt, '<'), (operator.le, '<='), + (operator.gt, '>'), (operator.ge, '>=')], + ids=['lt', 'le', 'gt', 'ge']) +def test_scalar_ops_comparisons_disallow_a_denominator_on_the_right( + func: Callable[[Scalar, Scalar], Boolean], symbol: str) -> None: + """The ordering comparisons reject a denominator in the right operand.""" + + a = Scalar([1., 2.]) + b = Scalar([[1., 5.], [0., 4.]], drank=1) + with pytest.raises(ValueError, match=f'Scalar "{symbol}" does not support denom'): + func(a, b) def test_scalar_ops_power_zero_without_derivatives() -> None: diff --git a/tests/test_scalar_sum.py b/tests/test_scalar_sum.py index 646b6a5..4bd6e53 100755 --- a/tests/test_scalar_sum.py +++ b/tests/test_scalar_sum.py @@ -2,6 +2,8 @@ # tests/test_scalar_mean.py ########################################################################################## +from collections.abc import Iterator + import numpy as np import pytest @@ -9,7 +11,7 @@ @pytest.fixture(autouse=True) -def _setup_teardown(): +def _setup_teardown() -> Iterator[None]: """Replaces the original setUp and tearDown methods.""" Qube.prefer_builtins(True) yield diff --git a/tests/test_typedefs.py b/tests/test_typedefs.py new file mode 100644 index 0000000..bfec005 --- /dev/null +++ b/tests/test_typedefs.py @@ -0,0 +1,308 @@ +########################################################################################## +# tests/test_typedefs.py +########################################################################################## + +import ast +import types +import typing +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from polymath import Boolean, Matrix, Matrix3, Pair, Quaternion, Qube, Scalar, Vector, Vector3 +from polymath import typedefs +from polymath.typedefs import (BooleanLike, MaskType, Matrix3Like, MatrixLike, PairLike, + QuaternionLike, QubeLike, ScalarLike, ValsType, Vector3Like, + VectorLike) + +SRC = Path(typedefs.__file__).parent + +# Every alias that names what a constructor accepts, with the class it belongs to and an +# item shape that class accepts +LIKE_ALIASES: dict[str, tuple[Any, type[Qube], tuple[int, ...]]] = { + 'BooleanLike': (BooleanLike, Boolean, ()), + 'ScalarLike': (ScalarLike, Scalar, ()), + 'PairLike': (PairLike, Pair, (2,)), + 'VectorLike': (VectorLike, Vector, (2,)), + 'Vector3Like': (Vector3Like, Vector3, (3,)), + 'MatrixLike': (MatrixLike, Matrix, (2, 2)), + 'Matrix3Like': (Matrix3Like, Matrix3, (3, 3)), + 'QuaternionLike': (QuaternionLike, Quaternion, (4,)), + 'QubeLike': (QubeLike, Qube, ()), +} + +# The aliases whose classes hold one number per item, so a lone number converts +NUMBER_ALIASES = ('BooleanLike', 'ScalarLike', 'QubeLike') + +NUMBER_TYPES = (float, int, bool, np.bool_) + +# The number types named by each alias that accepts a lone number. Pair.as_pair() repeats +# one across both components, so PairLike accepts one too, but a Pair holds no truth +# values, so it names only the numeric spellings. +SINGLE_NUMBER_TYPES: dict[str, tuple[type, ...]] = { + **dict.fromkeys(NUMBER_ALIASES, NUMBER_TYPES), + 'PairLike': (float, int), +} + + +def _array_member(alias: Any) -> Any: + """The one member of a union alias that is a parameterized numpy.ndarray.""" + + members = [arg for arg in typing.get_args(alias) if typing.get_origin(arg) is np.ndarray] + assert len(members) == 1 + return members[0] + + +def _shape_args(alias: Any) -> tuple[Any, ...]: + """The arguments of the shape type of the array member of a union alias.""" + + shape, _ = typing.get_args(_array_member(alias)) + return typing.get_args(shape) + + +def test_all_names_every_public_alias() -> None: + """The public names are exactly the twelve aliases, and each is a union.""" + + expected = ['BooleanLike', 'IntValsType', 'MaskType', 'Matrix3Like', 'MatrixLike', + 'PairLike', 'QuaternionLike', 'QubeLike', 'ScalarLike', 'ValsType', + 'Vector3Like', 'VectorLike'] + assert typedefs.__all__ == expected + + public_unions = sorted(name for name in dir(typedefs) + if not name.startswith('_') + and isinstance(getattr(typedefs, name), types.UnionType)) + assert public_unions == expected + + +@pytest.mark.parametrize('name', sorted(LIKE_ALIASES)) +def test_like_alias_accepts_qube(name: str) -> None: + """Every constructor alias accepts any PolyMath object.""" + + alias, _, _ = LIKE_ALIASES[name] + assert Qube in typing.get_args(alias) + + +@pytest.mark.parametrize('name', sorted(LIKE_ALIASES)) +def test_like_alias_accepts_nested_sequences(name: str) -> None: + """Every constructor alias accepts a sequence of numbers nested to any depth.""" + + alias, _, _ = LIKE_ALIASES[name] + assert typedefs._NestedSequence in typing.get_args(alias) + + +@pytest.mark.parametrize('name', sorted(LIKE_ALIASES)) +def test_like_alias_array_dtype(name: str) -> None: + """The array member of every constructor alias holds numbers or truth values.""" + + alias, _, _ = LIKE_ALIASES[name] + _, dtype = typing.get_args(_array_member(alias)) + assert typing.get_origin(dtype) is np.dtype + assert typing.get_args(dtype) == (np.number[Any] | np.bool_,) + + +@pytest.mark.parametrize('name', sorted(SINGLE_NUMBER_TYPES)) +def test_single_number_alias_accepts_single_numbers(name: str) -> None: + """Every alias that accepts a lone number names the types its class converts.""" + + alias, _, _ = LIKE_ALIASES[name] + members = typing.get_args(alias) + for number_type in SINGLE_NUMBER_TYPES[name]: + assert number_type in members + for number_type in set(NUMBER_TYPES) - set(SINGLE_NUMBER_TYPES[name]): + assert number_type not in members + + +@pytest.mark.parametrize('name', NUMBER_ALIASES) +def test_number_alias_array_has_free_shape(name: str) -> None: + """The aliases for rank-0 classes put no constraint on the shape of the array.""" + + alias, _, _ = LIKE_ALIASES[name] + assert _shape_args(alias) == (int, Ellipsis) + + +@pytest.mark.parametrize('name', sorted(set(LIKE_ALIASES) - set(SINGLE_NUMBER_TYPES))) +def test_item_alias_rejects_single_numbers(name: str) -> None: + """The aliases for classes with item axes do not accept a lone number.""" + + alias, _, _ = LIKE_ALIASES[name] + members = typing.get_args(alias) + for number_type in NUMBER_TYPES: + assert number_type not in members + + +@pytest.mark.parametrize('sequence_type', [list, tuple], ids=['list', 'tuple']) +def test_nested_sequence_of_arrays_converts(sequence_type: Callable[[Any], Any]) -> None: + """A sequence of arrays converts, stacked into one array of higher rank.""" + + rows = sequence_type([np.array([1., 2.]), np.array([3., 4.])]) + scalar = Scalar(rows) + assert type(scalar) is Scalar + assert scalar.shape == (2, 2) + assert np.array_equal(np.asarray(scalar.values), [[1., 2.], [3., 4.]]) + + +@pytest.mark.parametrize('sequence_type', [list, tuple], ids=['list', 'tuple']) +def test_nested_sequence_of_qubes_converts(sequence_type: Callable[[Any], Any]) -> None: + """A sequence of PolyMath objects converts the same way a sequence of arrays does.""" + + values = sequence_type([Scalar(1.), Scalar(2.)]) + scalar = Scalar(values) + assert type(scalar) is Scalar + assert scalar.shape == (2,) + assert np.array_equal(np.asarray(scalar.values), [1., 2.]) + + +@pytest.mark.parametrize('number', [0, 1, -3, 2.5, -0.75, np.float64(0.5)]) +def test_pairlike_single_number_converts(number: float) -> None: + """Every lone number that PairLike names converts to a Pair with the value repeated.""" + + pair = Pair.as_pair(number) + assert type(pair) is Pair + assert pair.shape == () + assert pair.numer == (2,) + values = np.asarray(pair.values) + assert values[0] == number + assert values[1] == number + + +@pytest.mark.parametrize( + ('name', 'trailing'), + [('PairLike', (2,)), ('Vector3Like', (3,)), ('QuaternionLike', (4,)), + ('Matrix3Like', (3, 3))], +) +def test_fixed_item_alias_names_trailing_axes(name: str, trailing: tuple[int, ...]) -> None: + """An alias for a fixed item shape fixes the trailing axes of the array as literals.""" + + alias, _, item = LIKE_ALIASES[name] + leading, *literals = _shape_args(alias) + assert typing.get_origin(leading) is tuple + assert typing.get_args(leading) == (int, Ellipsis) + assert [typing.get_args(literal) for literal in literals] == [(n,) for n in trailing] + assert trailing == item + + +@pytest.mark.parametrize(('name', 'minimum_axes'), [('VectorLike', 1), ('MatrixLike', 2)]) +def test_free_item_alias_requires_minimum_axes(name: str, minimum_axes: int) -> None: + """An alias for a free item shape requires at least the class's rank in axes.""" + + alias, _, item = LIKE_ALIASES[name] + *leading, trailing = _shape_args(alias) + assert leading == [int] * minimum_axes + assert typing.get_origin(trailing) is tuple + assert typing.get_args(trailing) == (int, Ellipsis) + assert minimum_axes == len(item) + + +def test_valstype_names_what_values_returns() -> None: + """ValsType is a number or a numeric array, matching the values property.""" + + members = typing.get_args(ValsType) + assert members[:4] == NUMBER_TYPES + assert typing.get_origin(members[4]) is np.ndarray + assert len(members) == 5 + + assert type(Scalar(1.5).values) in members + assert type(Scalar(2).values) in members + assert type(Boolean(True).values) in members + assert type(Scalar([1., 2.]).values) is np.ndarray + + +def test_masktype_names_what_mask_returns() -> None: + """MaskType is a truth value or a boolean array, matching the mask property.""" + + members = typing.get_args(MaskType) + assert members[:2] == (bool, np.bool_) + _, dtype = typing.get_args(members[2]) + assert typing.get_args(dtype) == (np.bool_,) + assert len(members) == 3 + + assert type(Scalar(1.).mask) is bool + mask = Scalar([1., 2.], mask=[True, False]).mask + assert type(mask) is np.ndarray + assert mask.dtype == np.bool_ + + +@pytest.mark.parametrize('name', sorted(LIKE_ALIASES)) +def test_constructor_accepts_each_kind_of_member(name: str) -> None: + """Each kind of value an alias names is accepted by the corresponding constructor.""" + + np.random.seed(4471) + _, cls, item = LIKE_ALIASES[name] + array: Any = np.random.randn(2, *item) + if cls is Boolean: + array = array > 0. + + from_array = cls(array) + assert from_array.shape == (2,) + assert from_array.numer == item + + from_list = cls(array.tolist()) + assert from_list.shape == (2,) + assert np.all(from_list.values == from_array.values) + + from_qube = cls(from_array) + assert type(from_qube) is cls + assert np.all(from_qube.values == from_array.values) + + if name in NUMBER_ALIASES: + single = cls(True) if cls is Boolean else cls(1.5) + assert single.shape == () + + +def test_aliases_usable_in_annotations() -> None: + """An alias is an ordinary object that an annotation can refer to directly.""" + + def speed(velocity: Vector3Like) -> Scalar: + return Vector3.as_vector3(velocity).norm() + + assert speed.__annotations__['velocity'] is Vector3Like + assert speed([3., 4., 0.]) == 5. + assert speed(np.array([0., 0., 2.])) == 2. + assert speed(Vector3.XAXIS) == 1. + + +def _alias_definitions(path: Path) -> dict[str, str]: + """Every type alias assigned in a module, keyed by name, as a normalized source dump.""" + + tree = ast.parse(path.read_text()) + aliases = {} + for node in tree.body: + if (isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + and isinstance(node.annotation, ast.Name) + and node.annotation.id == 'TypeAlias' and node.value is not None): + aliases[node.target.id] = ast.dump(node.value) + return aliases + + +def _all_list(path: Path) -> list[str]: + """The value of a module's __all__ list, read from its source.""" + + tree = ast.parse(path.read_text()) + for node in tree.body: + if (isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name) + and node.targets[0].id == '__all__' and isinstance(node.value, ast.List)): + return [ast.literal_eval(elt) for elt in node.value.elts] + raise AssertionError(f'no __all__ in {path}') + + +def test_stub_mirrors_module() -> None: + """typedefs.pyi defines the same aliases, in the same terms, as typedefs.py.""" + + module = SRC / 'typedefs.py' + stub = SRC / 'typedefs.pyi' + assert _all_list(stub) == _all_list(module) + assert _alias_definitions(stub) == _alias_definitions(module) + assert set(_alias_definitions(module)) >= set(typedefs.__all__) + + +def test_stub_takes_qube_from_the_package() -> None: + """The stub imports Qube from the package, the only supported import path.""" + + tree = ast.parse((SRC / 'typedefs.pyi').read_text()) + imports = [(node.module, [alias.name for alias in node.names]) + for node in tree.body if isinstance(node, ast.ImportFrom)] + assert ('polymath', ['Qube']) in imports + assert all(module != 'polymath.qube' for module, _ in imports) diff --git a/tests/test_units.py b/tests/test_units.py index f6c30e8..f31d09b 100755 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -136,8 +136,11 @@ def test_units_test_basic_initialization() -> None: u = Unit.KM assert Unit.as_unit(u) == u - with pytest.raises(ValueError): - Unit.as_unit(123) + with pytest.raises(TypeError, match='not a recognized unit'): + Unit.as_unit(123) # type: ignore[arg-type] # deliberately the wrong type + + with pytest.raises(KeyError): + Unit.as_unit('furlong') ################################################################################## # can_match(first, second) @@ -704,7 +707,7 @@ def test_units_test_basic_initialization() -> None: assert result == {} with pytest.raises(ValueError): - Unit.name_to_dict(123) + Unit.name_to_dict(123) # type: ignore[arg-type] # deliberately the wrong type with pytest.raises(ValueError, match='unexpected "5"'): Unit.name_to_dict('5') @@ -1548,7 +1551,7 @@ def test_units_name_to_dict_rejects_a_non_string() -> None: """name_to_dict() reports an argument that is neither a string nor a dictionary.""" with pytest.raises(ValueError, match='unit is not a string: "123"'): - Unit.name_to_dict(123) + Unit.name_to_dict(123) # type: ignore[arg-type] # deliberately the wrong type def test_units_name_to_dict_rejects_a_missing_operand() -> None: @@ -1630,3 +1633,9 @@ def test_units_unsupported_operand_raises_type_error(operation: Callable[[], obj with pytest.raises(TypeError, match=message): operation() + + +def test_second_name() -> None: + """The name of Unit.SECOND is exactly "second", with no trailing space.""" + + assert Unit.SECOND.name == 'second' diff --git a/tests/test_vector3_misc.py b/tests/test_vector3_misc.py index 08d1e72..7fb52b5 100755 --- a/tests/test_vector3_misc.py +++ b/tests/test_vector3_misc.py @@ -3,6 +3,8 @@ # Old Vector3 tests, updated by MRS 2/18/14 ########################################################################################## +from typing import Any + import numpy as np import pytest @@ -18,7 +20,7 @@ def test_vector3_misc_basic_comparisons_and_indexing() -> None: assert vecs.numer == (3,) assert vecs.shape == (3,) assert vecs.rank == 1 - test = [[1,2,3],[3,4,5],[5,6,7]] + test: Any = [[1,2,3],[3,4,5],[5,6,7]] assert vecs == test test = Vector3(test) assert vecs == test diff --git a/tests/test_vector3_spin.py b/tests/test_vector3_spin.py index 47da747..a9b1a0e 100755 --- a/tests/test_vector3_spin.py +++ b/tests/test_vector3_spin.py @@ -35,13 +35,13 @@ def test_vector3_spin_offset_angles() -> None: assert (np.all(abs(Z.spin(X, deg20) - (0., -sin20, cos20))).vals < EPS) assert (np.all(abs(Z.spin(Y, deg20) - (sin20, 0., cos20))).vals < EPS) - assert Z.offset_angles(Z) == (0.,0.) + assert Z.offset_angles(Z) == (0.,0.) # type: ignore[comparison-overlap] target = Vector3([0., sin20, cos20]) - assert Z.offset_angles(target) == (0., -deg20) + assert Z.offset_angles(target) == (0., -deg20) # type: ignore[comparison-overlap] test = Z.spin(X, -deg20) assert np.all(abs(test - target).vals < EPS) target = Vector3([sin20, 0., cos20]) - assert Z.offset_angles(target) == (deg20, 0.) + assert Z.offset_angles(target) == (deg20, 0.) # type: ignore[comparison-overlap] test = Z.spin(Y, deg20) assert np.all(abs(test - target).vals < EPS) start = Vector3([0., -sin20, cos20]) diff --git a/tests/test_vector_as_diagonal.py b/tests/test_vector_as_diagonal.py index 050e7bb..7e84cbc 100755 --- a/tests/test_vector_as_diagonal.py +++ b/tests/test_vector_as_diagonal.py @@ -31,8 +31,8 @@ def test_vector_as_diagonal_check_an_array_of_matrices_some_masked() -> None: b = a.as_diagonal() for i in range(4): for j in range(4): - aa = a.extract_numer(0, i, Scalar) - bb = b.extract_numer(0, i, Vector).extract_numer(0, j, Scalar) + aa = a.extract_numer(0, i, classes=Scalar) + bb = b.extract_numer(0, i, classes=Vector).extract_numer(0, j, classes=Scalar) if i == j: assert bb == aa diff --git a/tests/test_vector_as_index.py b/tests/test_vector_as_index.py index a8c0f97..9dd160d 100755 --- a/tests/test_vector_as_index.py +++ b/tests/test_vector_as_index.py @@ -74,10 +74,10 @@ def test_vector_as_index_array_to_test_for_indexing() -> None: vec = Vector([1,2,3], True) assert vec.as_index_and_mask(purge=True) == ((), False) indx, mask = vec.as_index_and_mask(purge=False) - assert indx == (1,2,3) + assert tuple(int(i) for i in indx) == (1,2,3) assert mask == True indx, mask = vec.as_index_and_mask(purge=False, masked=0) - assert indx == (0,0,0) + assert tuple(int(i) for i in indx) == (0,0,0) assert mask == True vals = np.arange(9).reshape(3,3) vec = Vector(vals, [False, False, True]) diff --git a/tests/test_vector_comprehensive.py b/tests/test_vector_comprehensive.py index 652bb6c..50ecdb6 100644 --- a/tests/test_vector_comprehensive.py +++ b/tests/test_vector_comprehensive.py @@ -493,6 +493,7 @@ def test_vector_comprehensive_test_from_scalars_with_readonly_parameter() -> Non v123 = Vector.from_scalars(s24, s25, readonly=True) assert isinstance(v123, Vector) + assert v123.readonly ########################################################################################## diff --git a/tests/test_vector_cross_3x3.py b/tests/test_vector_cross_3x3.py index 90bc634..50dd388 100755 --- a/tests/test_vector_cross_3x3.py +++ b/tests/test_vector_cross_3x3.py @@ -254,7 +254,7 @@ def test_vector_cross_product_as_matrix_supports_a_denominator() -> None: # Each denominator column is the cross-product matrix of that column of the input for j in range(2): - column = Vector(v.values[:, j]) + column = Vector(np.asarray(v.values)[:, j]) assert np.all(result.values[..., j] == column.cross_product_as_matrix().values) diff --git a/tests/test_vector_masking.py b/tests/test_vector_masking.py index 4ebe51e..9adf8a3 100755 --- a/tests/test_vector_masking.py +++ b/tests/test_vector_masking.py @@ -45,27 +45,27 @@ def test_vector_masking() -> None: ############################################################################################ # clip_component(), etc. ############################################################################################ - assert a.clip_component(2,2,8,False) == [[0,1,2],[3,4,5],[6,7,8]] - assert a.clip_component(2,2,7,False) == [[0,1,2],[3,4,5],[6,7,7]] - assert a.clip_component(2,2,6,False) == [[0,1,2],[3,4,5],[6,7,6]] - assert a.clip_component(2,2,3,False) == [[0,1,2],[3,4,3],[6,7,3]] - assert a.clip_component(2,2,None,False) == [[0,1,2],[3,4,5],[6,7,8]] - assert a.clip_component(2,None,3,False) == [[0,1,2],[3,4,3],[6,7,3]] - assert a.clip_component(2,2,8,True) == [[0,1,2],[3,4,5],[6,7,8]] - assert np.all(a.clip_component(2,2,7,True).mask == mask001) - assert np.all(a.clip_component(2,2,6,True).mask == mask001) - assert np.all(a.clip_component(2,2,3,True).mask == mask011) - assert np.all(a.clip_component(2,2,None,True).mask == mask000) + assert a.clip_component(2,2,8, remask=False) == [[0,1,2],[3,4,5],[6,7,8]] + assert a.clip_component(2,2,7, remask=False) == [[0,1,2],[3,4,5],[6,7,7]] + assert a.clip_component(2,2,6, remask=False) == [[0,1,2],[3,4,5],[6,7,6]] + assert a.clip_component(2,2,3, remask=False) == [[0,1,2],[3,4,3],[6,7,3]] + assert a.clip_component(2,2,None, remask=False) == [[0,1,2],[3,4,5],[6,7,8]] + assert a.clip_component(2,None,3, remask=False) == [[0,1,2],[3,4,3],[6,7,3]] + assert a.clip_component(2,2,8, remask=True) == [[0,1,2],[3,4,5],[6,7,8]] + assert np.all(a.clip_component(2,2,7, remask=True).mask == mask001) + assert np.all(a.clip_component(2,2,6, remask=True).mask == mask001) + assert np.all(a.clip_component(2,2,3, remask=True).mask == mask011) + assert np.all(a.clip_component(2,2,None, remask=True).mask == mask000) lower = Scalar([4,3,2]) upper = Scalar([5,4,3],mask=[0,1,0]) - assert a.clip_component(2,lower,upper,False) == [[0,1,4],[3,4,5],[6,7,3]] + assert a.clip_component(2,lower,upper, remask=False) == [[0,1,4],[3,4,5],[6,7,3]] def test_vector_clip_component_assigns_the_limit_value() -> None: """A shapeless Vector clips against an upper limit given as a plain number.""" - assert list(Vector([5., 0.]).clip_component(0, None, 2.).values) == [2., 0.] - assert list(Vector([-5., 0.]).clip_component(0, -2., None).values) == [-2., 0.] + assert list(np.asarray(Vector([5., 0.]).clip_component(0, None, 2.).values)) == [2., 0.] + assert list(np.asarray(Vector([-5., 0.]).clip_component(0, -2., None).values)) == [-2., 0.] ########################################################################################## diff --git a/tests/test_vector_reciprocal.py b/tests/test_vector_reciprocal.py index 5b832ad..ed160c7 100755 --- a/tests/test_vector_reciprocal.py +++ b/tests/test_vector_reciprocal.py @@ -14,11 +14,11 @@ def test_vector_reciprocal_print_np_abs_diffs_max_the_tolerance_is_set_by_float6 np.random.seed(4912) vec = Pair([[1,0],[0,2]], drank=1) inverse = vec.reciprocal() - assert np.all(inverse == [[1,0],[0,0.5]]) + assert inverse == [[1,0],[0,0.5]] assert type(inverse) is type(vec) vec = Vector3([[0,1,0],[0,0,2],[4,0,0]], drank=1) inverse = vec.reciprocal() - assert np.all(inverse == [[0,0,0.25],[1,0,0],[0,0.5,0]]) + assert inverse == [[0,0,0.25],[1,0,0],[0,0.5,0]] assert type(inverse) is type(vec) N = 100 vec = Vector(np.random.randn(N,4,4), drank=1)