Source code for Karana.Core._KCore_Py

# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

"""KCore contains a collection of classes that form core infrastructure for many other classes through Karana software.

Examples include Base and LockingBase which serve as base classes for most simulation-related objects.
"""

# Ignore F405 for the whole file. This takes care of things imported from *
# ruff: noqa: F405

import datetime
from os import getenv
from collections.abc import Callable
from typing import (
    Annotated,
    TYPE_CHECKING,
    Self,
    TypeVar,
    cast,
    overload,
    TypeAlias,
    Union,
    Generic,
    get_args,
    get_origin,
    Any,
)
from traceback import format_stack

import numpy as np
from numpy.typing import NDArray
import Karana.Math as km
from Karana.Math import Ktyping as kt, Kquantities as kq

# Define Json type alias before loading in pybind11
Json: TypeAlias = dict[str, Union[str, int, float, "Json"]]
from Karana.Core._KCore_pybind11_Py import *  # noqa: E402, F403
from Karana.Core._KCore_pybind11_Py import deprecated as _deprecated  # noqa: E402
from Karana.Core._KCore_pybind11_Py import Var as _BoundVar  # noqa: E402
import Karana.Core._KCore_pybind11_Py as _bindings  # noqa: E402


Char: TypeAlias = Annotated[str, "Char"]
"""A single character stored by the C++ char specialization."""


def _specializeVar(cls: type[_BoundVar], value_type: object) -> type[_BoundVar]:
    """Select a bound C++ Var specialization without constructing an object.

    Parameters
    ----------
    cls : type[Var]
        The subscribed class; subscription is supported on the Var base only.
    value_type : object
        A supported Python scalar, Ktyping array alias,
        or Karana.Math class.

    Returns
    -------
    type[Var]
        The existing concrete class, with its constructors and create method.

    Raises
    ------
    TypeError
        If the class or value type has no registered specialization.
    """
    if cls is not _BoundVar:
        raise TypeError("Use Var[value_type] to select a specialization")

    specializations: dict[object, type[_BoundVar]] = {
        float: _bindings._VarDouble,
        np.float64: _bindings._VarDouble,
        np.float32: _bindings._VarFloat,
        int: _bindings._VarInt,
        np.int16: _bindings._VarShort,
        np.int32: _bindings._VarInt,
        np.int64: _bindings._VarLong,
        np.uint64: _bindings._VarSizeT,
        bool: _bindings._VarBool,
        str: _bindings._VarString,
        Char: _bindings._VarChar,
        list[str]: _bindings._VarVecString,
        kt.Vec: _bindings._VarVec,
        kt.Vec3: _bindings._VarVec3,
        kt.Vec4: _bindings._VarVec4,
        kt.Vec6: _bindings._VarVec6,
        kt.VecInt: _bindings._VarVecInt,
        kt.Mat: _bindings._VarMat,
        kt.Mat33: _bindings._VarMat33,
        kt.Mat66: _bindings._VarMat66,
        km.UnitQuaternion: _bindings._VarUnitQuaternion,
        km.SpatialVector: _bindings._VarSpatialVector,
        km.SpatialVelocity: _bindings._VarSpatialVelocity,
        km.SpatialAcceleration: _bindings._VarSpatialAcceleration,
        km.SpatialForce: _bindings._VarSpatialForce,
        km.HomTran: _bindings._VarHomTran,
    }
    origin = get_origin(value_type)
    if origin is not None and getattr(origin, "__module__", "").startswith("Karana.Math.Ktyping"):
        (magnitude,) = get_args(value_type)
        quantity_name = getattr(origin, "__name__", "")
        numeric = specializations.get(magnitude)
        if numeric is not None:
            for suffix in (
                "Double",
                "Float",
                "Vec3",
                "Vec4",
                "Vec6",
                "Vec",
                "Mat33",
                "Mat66",
                "Mat",
            ):
                if numeric is getattr(_bindings, "_Var" + suffix):
                    bound = getattr(_bindings, "_Var" + quantity_name + suffix, None)
                    if bound is not None:
                        return bound
        raise TypeError(f"Unsupported quantity Var value type: {value_type!r}")
    try:
        return specializations[value_type]
    except (KeyError, TypeError):
        raise TypeError(f"Unsupported Var value type: {value_type!r}") from None


def _resolveUnits(quantity_name: str, specification: object) -> Any:
    """Resolve specification in Karana's registry for quantity_name; return a Unit.

    Empty strings use the quantity's configured default units. Explicit units
    must match the physical quantity.
    """
    expected = getattr(kq, quantity_name)
    if specification == "":
        return kq.getDefaultUnits(expected)
    units = kq.ureg.Unit(str(specification))
    if not kq.ureg.Quantity(1, units).check(expected):
        raise ValueError(f"Expected {quantity_name} units, got {units}")
    return units


def _resolveVarUnits(var: Any) -> Any:
    """Resolve var's declared units once per Python instance; return its Unit."""
    if not hasattr(var, "_resolved_units"):
        var._resolved_units = _resolveUnits(var._quantity_name, var.units())
    return var._resolved_units


def _quantityConstructor(
    quantity_alias: str,
    fn: Callable[[], object],
    args: tuple[object, ...],
    kwargs: dict[str, object],
) -> tuple:
    """Normalize quantity Var arguments; return numeric callback, dimensions, description and units.

    quantity_alias selects Math metadata. fn may return a raw magnitude or a Pint
    quantity. args and kwargs hold numeric Var constructor dimensions and metadata.
    """
    from pint import Quantity  # pylint: disable=import-outside-toplevel

    values = list(args)
    dimensions = []
    while values and isinstance(values[0], int):
        dimensions.append(values.pop(0))
    options = dict(kwargs)
    for key in ("vector_size", "matrix_rows", "matrix_cols"):
        if key in options:
            dimensions.append(options.pop(key))
    description = values.pop(0) if values else options.pop("description")
    specification = values.pop(0) if values else options.pop("units", "")
    if values or options:
        raise TypeError("Unexpected quantity Var constructor arguments")

    # The native class supplies the semantic name
    quantity_name = getattr(_bindings, "_Var" + quantity_alias + "Double")._quantity_name
    units = _resolveUnits(quantity_name, specification)

    def numeric() -> object:
        """Invoke fn, converting quantity results to declared units; return the magnitude."""
        value: Any = fn()
        if isinstance(value, Quantity):
            value = value.to(units).magnitude

        # Logging allocates storage from the declared dynamic dimensions. Reject
        # a changing callback shape before C++ can copy its numeric data.
        if dimensions and np.shape(value) != tuple(dimensions):
            raise ValueError(f"Expected magnitude shape {tuple(dimensions)}, got {np.shape(value)}")

        return value

    # Callback conversion needs a Unit object; the native constructor resolves
    # the original specification into the canonical string stored on the Var.
    return numeric, dimensions, description, str(specification)


def _quantityTo(self: Any, units: object) -> Any:
    """Return a native Var expressing self's numeric output in the requested units."""
    source = _resolveVarUnits(self)
    target = _resolveUnits(self._quantity_name, units)
    factor = source._REGISTRY.Quantity(1, source).to(target).magnitude
    return self._scaled(factor, str(target))


def _quantityCreate(cls: type, *args: object, **kwargs: object) -> object:
    """Construct cls from positional args and keyword kwargs; return the quantity Var."""
    return cls(*args, **kwargs)


# Returned C++ objects use these registered classes too. Naming the classes
# gives constructed and returned Vars the same display without wrapping them.
# Stubgen needs valid identifiers for its private concrete declarations, so
# only the display-name changes are skipped while generating stubs.
setattr(_BoundVar, "__class_getitem__", classmethod(_specializeVar))
if not getenv("KARANA_STUBGEN"):
    for _varClass, _valueName in {
        _bindings._VarBool: "bool",
        _bindings._VarDouble: "float",
        _bindings._VarFloat: "np.float32",
        _bindings._VarShort: "np.int16",
        _bindings._VarInt: "int",
        _bindings._VarLong: "np.int64",
        _bindings._VarSizeT: "np.uint64",
        _bindings._VarChar: "Char",
        _bindings._VarString: "str",
        _bindings._VarVecString: "list[str]",
        _bindings._VarVec: "Vec",
        _bindings._VarVec3: "Vec3",
        _bindings._VarVec4: "Vec4",
        _bindings._VarVec6: "Vec6",
        _bindings._VarVecInt: "VecInt",
        _bindings._VarMat: "Mat",
        _bindings._VarMat33: "Mat33",
        _bindings._VarMat66: "Mat66",
        _bindings._VarUnitQuaternion: "UnitQuaternion",
        _bindings._VarSpatialVector: "SpatialVector",
        _bindings._VarSpatialVelocity: "SpatialVelocity",
        _bindings._VarSpatialAcceleration: "SpatialAcceleration",
        _bindings._VarSpatialForce: "SpatialForce",
        _bindings._VarHomTran: "HomTran",
    }.items():
        _genericName = f"Var[{_valueName}]"
        _varClass.__name__ = _genericName
        _varClass.__qualname__ = _genericName
        # Match the qualified class name with an attribute for introspection.
        globals()[_genericName] = _varClass
        _numericClass = _varClass.__base__
        _numericName = f"NumericVar[{_valueName}]"
        _numericClass.__name__ = _numericName
        _numericClass.__qualname__ = _numericName
        globals()[_numericName] = _numericClass


# NumericVar[T] selects the shared native interface, while Var[T] constructs
# the concrete unitless specialization. Static annotations below describe both.
class _NumericVarMeta(type(_BoundVar)):
    """Recognize native Var objects through the NumericVar runtime facade."""

    def __instancecheck__(cls, value: object) -> bool:
        """Return whether value is a native Var instance."""
        return isinstance(value, _BoundVar)

    def __subclasscheck__(cls, value: type) -> bool:
        """Return whether value inherits the common native Var base."""
        return issubclass(value, _BoundVar)


class NumericVar(_BoundVar, metaclass=_NumericVarMeta):
    """Runtime facade for native numeric interfaces and quantity-aware inputs."""

[docs] @classmethod def __class_getitem__(cls, value_type: object) -> type[_BoundVar]: """Return the numeric base for magnitude value_type; reject quantity aliases.""" concrete = _specializeVar(_BoundVar, value_type) if "_quantity_alias" in vars(concrete): raise TypeError("NumericVar expects a magnitude type") return concrete.__base__
for _quantityClass in vars(_bindings).values(): if isinstance(_quantityClass, type) and "_quantity_alias" in vars(_quantityClass): _quantityClass.to = _quantityTo _quantityClass.create = classmethod(_quantityCreate) if not getenv("KARANA_STUBGEN"): _suffix = _quantityClass._magnitude_name _magnitudeLabel = {"Double": "float", "Float": "np.float32"}.get(_suffix, _suffix) _label = f"Var[{_quantityClass._quantity_alias}[{_magnitudeLabel}]]" _quantityClass.__name__ = _label _quantityClass.__qualname__ = _label globals()[_label] = _quantityClass if TYPE_CHECKING: from pint.facets.numpy.quantity import NumpyQuantity as _NumpyQuantity from pint.facets.plain.quantity import PlainQuantity as _PlainQuantity _ValueT = TypeVar("_ValueT") _MagnitudeT = TypeVar("_MagnitudeT", bound=float | np.floating | NDArray) _PlotArrayT = TypeVar("_PlotArrayT", bound=kt.Vec | kt.Vec3) _PlotSpatialT = TypeVar( "_PlotSpatialT", km.SpatialVector, km.SpatialVelocity, km.SpatialAcceleration, km.SpatialForce, ) class Var(_BoundVar, Generic[_ValueT]): """A callable value with an optional compile-time physical quantity.""" @overload def __init__( self: "_TypedVar[_NumpyQuantity[_MagnitudeT]]", name: str, fn: Callable[[], _MagnitudeT | _PlainQuantity[Any]], description: str, units: object = "", ) -> None: """Create a new Var. Parameters ---------- name : str The name of the Var[T] fn : Callable[[], T | Quantity] The callable that produces type T, either as a numeric value or a Quantity. Quantity results are converted to units before passing their magnitudes to C++. description : str A description of the Var. units : str or pint.Unit, optional The units of the Var. An empty string selects the configured default units. """ ... @overload def __init__( self: "_TypedVar[_NumpyQuantity[_MagnitudeT]]", name: str, fn: Callable[[], _MagnitudeT | _PlainQuantity[Any]], vector_size: int, description: str, units: object = "", ) -> None: """Create a new instance of Var[Vec]. Parameters ---------- name : str The name of the new Var[Vec] fn : Callable[[], T | Quantity] The callable that produces Vec, either as a numeric value or a Quantity. Quantity results are converted to units before passing their magnitudes to C++. vector_size : int The size of the km::Vec that the callable produces. This must always be the same size. description : str A description of the Var. units : str or pint.Unit, optional The units of the Var. An empty string selects the configured default units. """ ... @overload def __init__( self: "_TypedVar[_NumpyQuantity[_MagnitudeT]]", name: str, fn: Callable[[], _MagnitudeT | _PlainQuantity[Any]], matrix_rows: int, matrix_cols: int, description: str, units: object = "", ) -> None: """Create a new instance of Var[Mat]. Parameters ---------- name : str The name of the Var[Mat] fn : Callable[[], T | Quantity] The callable that produces Mat, either as a numeric value or a Quantity. Quantity results are converted to units before passing their magnitudes to C++. matrix_rows : int The number of rows in the km::Mat that the callable produces. The matrix that the callable produces must always be the same size. matrix_cols : int The number of cols in the km::Mat that the callable produces. The matrix that the callable produces must always be the same size. description : str A description of the Var. units : str or pint.Unit, optional The units of the Var. An empty string selects the configured default units. """ ... @overload def __init__( self, name: str, fn: Callable[[], _ValueT], description: str, units: object = "" ) -> None: """Create a new instance of Var[T]. Parameters ---------- name : str The name of the Var[T] fn : Callable[[], T] The callable that produces type T. description : str A description of the Var. units : str, optional The units of the Var. """ ... @overload def __init__( self, name: str, fn: Callable[[], _ValueT], vector_size: int, description: str, units: object = "", ) -> None: """Create a new instance of Var[Vec]. Parameters ---------- name : str The name of the new Var[Vec] fn : Callable[[], T] The callable that produces a Vec. vector_size : int The size of the km::Vec that the callable produces. This must always be the same size. description : str A description of the Var. units : str, optional The units of the Var. """ ... @overload def __init__( self, name: str, fn: Callable[[], _ValueT], matrix_rows: int, matrix_cols: int, description: str, units: object = "", ) -> None: """Create a new instance of Var[Mat]. Parameters ---------- name : str The name of the Var[Mat] fn : Callable[[], T] The callable that produces a Mat. matrix_rows : int The number of rows in the km::Mat that the callable produces. The matrix that the callable produces must always be the same size. matrix_cols : int The number of cols in the km::Mat that the callable produces. The matrix that the callable produces must always be the same size. description : str A description of the Var. units : str, optional The units of the Var. """ ... @overload def __init__(self, name: str, other: Self, description: str) -> None: """Rename Var constructor. This constructor keeps the same function, size, etc., but takes a new name and description. Parameters ---------- name : str The name of the Var[T] other : Var[T] The Var[T] to copy the callable and units from. description : str A description of the Var. """ ... def __init__(self, *args: object, **kwargs: object) -> None: """Construct a numeric Var and resolve its units if Python is available. Parameters ---------- *args : tuple Name, callable or source Var, dimensions, description and units. **kwargs : dict Constructor arguments supplied by name. """ ... @overload def __call__(self: "_TypedVar[np.float32] | _TypedVar[np.float64]") -> float: """Return the value that the Var contains. Returns ------- float The value that the Var contains. """ ... @overload def __call__( self: "_TypedVar[np.int16] | _TypedVar[np.int32] | _TypedVar[np.int64] | _TypedVar[np.uint64]", ) -> int: """Return the value that the Var contains. Returns ------- int The value that the Var contains. """ ... @overload def __call__(self) -> _ValueT: """Return the value that the Var contains. Returns ------- T The value that the Var contains. """ ... def __call__(self) -> object: """Return the value that the Var contains. Returns ------- object The value that the Var contains. """ ... @overload def getPlottingFunction( self: "_TypedVar[float] | _TypedVar[np.float64] | _TypedVar[_NumpyQuantity[float]] | _TypedVar[_NumpyQuantity[np.float64]]", ) -> Callable[[], float]: """Return the plotting function associated with this Var. Returns ------- Callable[[], float] The plotting function associated with this Var. For quantity Vars, the function returns raw magnitudes in the Var's units. """ ... @overload def getPlottingFunction( self: "_TypedVar[_PlotArrayT] | _TypedVar[_NumpyQuantity[_PlotArrayT]]", ) -> Callable[[], _PlotArrayT]: """Return the plotting function associated with this Var. Returns ------- Callable[[], T] The plotting function associated with this Var. For quantity Vars, the function returns raw magnitudes in the Var's units. """ ... @overload def getPlottingFunction(self: "_TypedVar[_PlotSpatialT]") -> Callable[[], _PlotSpatialT]: """Return the plotting function associated with this Var. Returns ------- Callable[[], T] The plotting function associated with this Var. """ ... def getPlottingFunction(self) -> Callable[[], object]: """Return the plotting function associated with this Var. Returns ------- Callable[[], object] The plotting function associated with this Var. """ ... def to( self: "_TypedVar[_NumpyQuantity[_MagnitudeT]]", units: object ) -> "_TypedVar[_NumpyQuantity[_MagnitudeT]]": """Return a Var with this quantity and magnitude type expressed in units. Parameters ---------- units : str or pint.Unit Unit specification describing the scaled numeric values. Returns ------- Var[T] A quantity Var with a native scaled callable and logging function. """ ... @classmethod def create(cls, *args: object, **kwargs: object) -> Self: """Create a new instance of Var[T]. Parameters ---------- *args : tuple Name, callable or source Var, dimensions, description and units. The callable may produce a numeric value or a Quantity for a quantity Var. **kwargs : dict Constructor arguments supplied by name. Returns ------- Var[T] The newly created instance of Var[T]. """ ... # Self annotations distinguish the generic interface from the native base. _TypedVar: TypeAlias = Var type NumericVar[T: float | np.floating | NDArray] = _TypedVar[T] | _TypedVar[_NumpyQuantity[T]] """A numeric Var or a quantity Var with the same underlying magnitude type.""" # Giving credit to Basilisk. See https://github.com/AVSLab/basilisk/blob/19e5538bed96141634fac4570b5b50abb01836a3/src/utilities/deprecated.py @overload def deprecated(name: str, removal_date: datetime.date | str, msg: str): """ Print a deprecation message. Parameters ---------- name : str Name of the class, function, etc. being deprecated. removal_date : datetime.date | str The date at which the deprecated class, function, etc. will be removed. msg : str A message telling the user how to fix their deprecated code. """ ... # pragma: no cover. Will not be run by code coverage. @overload def deprecated(removal_date: datetime.date | str, msg: str): """Use this decorator is used to mark functions or classes as deprecated. Usage:: @deprecated.deprecated("2024-05-28", "Use MyNewClass") class MyClass: ... or:: @deprecated.deprecated("2024-05-28", "myFun is unsafe now") def myFun(a, b): ... or:: class ValidClass: @deprecated.deprecated("2024-05-28", "myMethod is slow, use myBetterMethod") def myMethod(self, a, b): ... Parameters ---------- removal_date : datetime.date | str The date at which the deprecated class, function, etc. will be removed. msg : str A message telling the user how to fix their deprecated code. """ ... # pragma: no cover. Will not be run by code coverage. def deprecated(*args, **_): """Use this decorator is used to mark functions or classes as deprecated. See overloaded function signatures for details. """ if len(args) == 2: # Decorator def wrapper(func): name = f"{func.__module__}.{func.__qualname__}" removal_date = args[0] if isinstance(removal_date, str): removal_date = datetime.datetime.strptime(removal_date, r"%Y-%m-%d").date() msg = args[1] def innerWrapper(*args, **kwargs): deprecated(name, removal_date, msg) return func(*args, **kwargs) return innerWrapper return wrapper elif len(args) == 3: name = args[0].replace(")", "").replace("(", "") removal_date = args[1] if isinstance(removal_date, str): removal_date = datetime.datetime.strptime(removal_date, r"%Y-%m-%d").date() stack = "".join(format_stack()[:-1]) _deprecated(name, str(removal_date), args[2] + "\nStack follows:\n" + stack) else: raise ValueError("Got an invalid number of arguments.") T = TypeVar("T", bound=Base) class CppWeakRef(CppWeakRefBase, Generic[T]): """Weak reference to C++ object. Use the get() method to return a Python object from the C++ object if it exists. """ def __init__(self, base: T): """Create a CppWeakRef. Parameters ---------- base : T Object to create a CppWeakRef to. """ super().__init__(base)
[docs] def __call__(self) -> T | None: """Return a Python object for the C++ class if it exists, or None if it does not. Returns ------- T | None The Python object if the C++ object still exists. None if it does not. """ return cast(T, super().__call__())