Source code for movement.kinematics.kinetic_energy

"""Functions for computing kinetic energy."""

import numpy as np
import xarray as xr

from movement.kinematics.kinematics import compute_velocity
from movement.utils.logging import logger
from movement.utils.vector import compute_norm
from movement.validators.arrays import validate_dims_coords


[docs] def compute_kinetic_energy( position: xr.DataArray, keypoints: list | None = None, masses: dict | None = None, decompose: bool = False, ) -> xr.DataArray: r"""Compute kinetic energy per individual. We consider each individual's set of keypoints (pose) as a classical system of particles in physics (see Notes). Parameters ---------- position : xr.DataArray The input data containing position information, with ``time``, ``space`` and ``keypoints`` as required dimensions. keypoints : list, optional A list of keypoint names to include in the computation. By default, all are used. masses : dict, optional A dictionary mapping keypoint names to masses, e.g. {"snout": 1.2, "tail": 0.8}. By default, unit mass is assumed for all keypoints. decompose : bool, optional If True, the kinetic energy is decomposed into "translational" and "internal" components (see Notes). This requires at least two keypoints per individual, but more would be desirable for a meaningful decomposition. The default is False, meaning the total kinetic energy is returned. Returns ------- xr.DataArray A data array containing the kinetic energy per individual, for every time point. Note that the output array lacks ``space`` and ``keypoints`` dimensions. If ``decompose=True`` an extra ``energy`` dimension is added, with coordinates ``translational`` and ``internal``. Notes ----- Considering a given individual at time point :math:`t` as a system of keypoint particles, its total kinetic energy :math:`T_{total}` is given by: .. math:: T_{total} = \sum_{i} \frac{1}{2} m_i \| \mathbf{v}_i(t) \|^2 where :math:`m_i` is the mass of the :math:`i`-th keypoint and :math:`\mathbf{v}_i(t)` is its velocity at time :math:`t`. From Samuel König's second theorem, we can decompose :math:`T_{total}` into: - Translational kinetic energy: the kinetic energy of the individual's total mass :math:`M` moving with the centre of mass velocity; - Internal kinetic energy: the kinetic energy of the keypoints moving relative to the individual's centre of mass. We compute translational kinetic energy :math:`T_{trans}` as follows: .. math:: T_{trans} = \frac{1}{2} M \| \mathbf{v}_{cm}(t) \|^2 where :math:`M = \sum_{i} m_i` is the total mass of the individual and :math:`\mathbf{v}_{cm}(t) = \frac{1}{M} \sum_{i} m_i \mathbf{v}_i(t)` is the velocity of the centre of mass at time :math:`t` (computed as the weighted mean of keypoint velocities). Internal kinetic energy :math:`T_{int}` is derived as the difference between the total and translational components: .. math:: T_{int} = T_{total} - T_{trans} Examples -------- >>> from movement.kinematics import compute_kinetic_energy >>> import numpy as np >>> import xarray as xr Compute total kinetic energy: >>> position = xr.DataArray( ... np.random.rand(3, 2, 4, 2), ... coords={ ... "time": np.arange(3), ... "individuals": ["id0", "id1"], ... "keypoints": ["snout", "spine", "tail_base", "tail_tip"], ... "space": ["x", "y"], ... }, ... dims=["time", "individuals", "keypoints", "space"], ... ) >>> kinetic_energy_total = compute_kinetic_energy(position) >>> kinetic_energy_total <xarray.DataArray (time: 3, individuals: 2)> Size: 48B 0.6579 0.7394 0.1304 0.05152 0.2436 0.5719 Coordinates: * time (time) int64 24B 0 1 2 * individuals (individuals) <U3 24B 'id0' 'id1' Compute kinetic energy decomposed into translational and internal components: >>> kinetic_energy = compute_kinetic_energy(position, decompose=True) >>> kinetic_energy <xarray.DataArray (time: 3, individuals: 2, energy: 2)> Size: 96B 0.0172 1.318 0.02069 0.6498 0.02933 ... 0.1716 0.07829 0.7942 0.06901 0.857 Coordinates: * time (time) int64 24B 0 1 2 * individuals (individuals) <U3 24B 'id0' 'id1' * energy (energy) <U13 104B 'translational' 'internal' Select the 'internal' component: >>> kinetic_energy_internal = kinetic_energy.sel(energy="internal") Use unequal keypoint masses and exclude an unreliable keypoint (e.g. "tail_tip"): >>> masses = {"snout": 1.2, "spine": 0.8, "tail_base": 1.0} >>> kinetic_energy = compute_kinetic_energy( ... position, ... keypoints=["snout", "spine", "tail_base"], ... masses=masses, ... decompose=True, ... ) """ # Validate required dimensions and coordinate labels validate_dims_coords( position, {"time": [], "space": ["x", "y"], "keypoints": []} ) # Subset keypoints if requested if keypoints is not None: position = position.sel(keypoints=keypoints) # Validate that at least 2 keypoints exist for decomposition if decompose and position.sizes["keypoints"] < 2: raise logger.error( ValueError( "At least 2 keypoints are required to decompose " "kinetic energy into translational and internal components." ) ) # Compute velocity from position velocity = compute_velocity(position) # Initialise unit weights weights = xr.DataArray( np.ones(position.sizes["keypoints"]), dims=["keypoints"], coords={"keypoints": position.coords["keypoints"]}, ) # Update weights with keypoint masses, if provided if masses: for keypoint, mass in masses.items(): weights.loc[keypoint] = mass # Compute total KE weighted_ke = 0.5 * weights * (compute_norm(velocity) ** 2) ke_total = weighted_ke.sum(dim="keypoints") if not decompose: return ke_total else: # Compute translational KE based on centre of mass velocity v_cm = (velocity * weights.expand_dims(space=["x", "y"])).sum( dim="keypoints" ) / weights.sum() ke_trans = 0.5 * weights.sum() * compute_norm(v_cm) ** 2 # Internal KE ke_int = ke_total - ke_trans # Format output ke = xr.concat([ke_trans, ke_int], dim="energy") ke = ke.assign_coords(energy=["translational", "internal"]) ke = ke.transpose("time", ..., "energy") return ke