|
|
""" |
|
|
Tether Dynamics Module |
|
|
====================== |
|
|
Physics engine for cable tension, constraints, and snap mechanics. |
|
|
|
|
|
This module handles: |
|
|
- Spring-damper cable model (Kelvin-Voigt viscoelastic) |
|
|
- Tension calculations under aerodynamic load |
|
|
- Cable release/snap mechanics |
|
|
- Whip prevention algorithms |
|
|
""" |
|
|
|
|
|
import numpy as np |
|
|
from dataclasses import dataclass |
|
|
from typing import Tuple, Optional |
|
|
from enum import Enum |
|
|
|
|
|
|
|
|
class CableState(Enum): |
|
|
"""Cable operational states""" |
|
|
ATTACHED = "attached" |
|
|
RELEASING = "releasing" |
|
|
SEVERED = "severed" |
|
|
SLACK = "slack" |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class CableProperties: |
|
|
"""Physical properties of tether cable""" |
|
|
length: float |
|
|
diameter: float |
|
|
breaking_strength: float |
|
|
stiffness: float |
|
|
damping: float |
|
|
linear_density: float |
|
|
max_tension: float |
|
|
min_tension: float |
|
|
|
|
|
|
|
|
class TetherConstraint: |
|
|
""" |
|
|
Models a single tether cable between mother drone and TAB. |
|
|
|
|
|
Uses spring-damper (Kelvin-Voigt) model for realistic stretch behavior: |
|
|
|
|
|
F_tension = k * (L - L0) + c * (dL/dt) |
|
|
|
|
|
Where: |
|
|
k = stiffness coefficient |
|
|
c = damping coefficient |
|
|
L = current length |
|
|
L0 = nominal length |
|
|
dL/dt = rate of length change |
|
|
""" |
|
|
|
|
|
def __init__(self, |
|
|
cable_id: str, |
|
|
properties: CableProperties, |
|
|
anchor_point_mother: np.ndarray, |
|
|
anchor_point_tab: np.ndarray): |
|
|
|
|
|
self.cable_id = cable_id |
|
|
self.props = properties |
|
|
self.state = CableState.ATTACHED |
|
|
|
|
|
|
|
|
self.anchor_mother = np.array(anchor_point_mother, dtype=np.float64) |
|
|
self.anchor_tab = np.array(anchor_point_tab, dtype=np.float64) |
|
|
|
|
|
|
|
|
self.current_tension = 0.0 |
|
|
self.current_length = properties.length |
|
|
self.stretch_rate = 0.0 |
|
|
|
|
|
|
|
|
cross_section = np.pi * (properties.diameter / 2) ** 2 |
|
|
self.spring_k = (properties.stiffness * cross_section) / properties.length |
|
|
self.damper_c = properties.damping * 2 * np.sqrt(self.spring_k * properties.linear_density * properties.length) |
|
|
|
|
|
|
|
|
self._prev_length = properties.length |
|
|
self._prev_time = 0.0 |
|
|
|
|
|
def compute_tension_force(self, |
|
|
pos_mother: np.ndarray, |
|
|
pos_tab: np.ndarray, |
|
|
vel_mother: np.ndarray, |
|
|
vel_tab: np.ndarray, |
|
|
dt: float) -> Tuple[np.ndarray, np.ndarray, float]: |
|
|
""" |
|
|
Calculate tension forces on both ends of the cable. |
|
|
|
|
|
Returns: |
|
|
force_on_mother: Force vector applied to mother drone (N) |
|
|
force_on_tab: Force vector applied to TAB (N) |
|
|
tension_magnitude: Scalar tension value (N) |
|
|
""" |
|
|
if self.state == CableState.SEVERED: |
|
|
return np.zeros(3), np.zeros(3), 0.0 |
|
|
|
|
|
|
|
|
displacement = pos_tab - pos_mother |
|
|
self.current_length = np.linalg.norm(displacement) |
|
|
|
|
|
if self.current_length < 1e-6: |
|
|
return np.zeros(3), np.zeros(3), 0.0 |
|
|
|
|
|
|
|
|
cable_direction = displacement / self.current_length |
|
|
|
|
|
|
|
|
stretch = self.current_length - self.props.length |
|
|
|
|
|
|
|
|
if dt > 0: |
|
|
self.stretch_rate = (self.current_length - self._prev_length) / dt |
|
|
self._prev_length = self.current_length |
|
|
|
|
|
|
|
|
|
|
|
if stretch > 0: |
|
|
spring_force = self.spring_k * stretch |
|
|
damper_force = self.damper_c * self.stretch_rate |
|
|
tension_magnitude = max(0, spring_force + damper_force) |
|
|
else: |
|
|
|
|
|
tension_magnitude = 0.0 |
|
|
self.state = CableState.SLACK |
|
|
|
|
|
|
|
|
if tension_magnitude > self.props.breaking_strength: |
|
|
|
|
|
self._trigger_snap() |
|
|
return np.zeros(3), np.zeros(3), 0.0 |
|
|
|
|
|
tension_magnitude = min(tension_magnitude, self.props.max_tension) |
|
|
self.current_tension = tension_magnitude |
|
|
|
|
|
|
|
|
if tension_magnitude < self.props.min_tension: |
|
|
self.state = CableState.SLACK |
|
|
elif self.state == CableState.SLACK: |
|
|
self.state = CableState.ATTACHED |
|
|
|
|
|
|
|
|
force_on_mother = cable_direction * tension_magnitude |
|
|
force_on_tab = -cable_direction * tension_magnitude |
|
|
|
|
|
return force_on_mother, force_on_tab, tension_magnitude |
|
|
|
|
|
def release(self, whip_prevention: bool = True) -> np.ndarray: |
|
|
""" |
|
|
Execute cable release sequence. |
|
|
|
|
|
Args: |
|
|
whip_prevention: If True, applies damping to prevent cable striking mother |
|
|
|
|
|
Returns: |
|
|
final_momentum: Momentum vector transferred to TAB at release |
|
|
""" |
|
|
if self.state == CableState.SEVERED: |
|
|
return np.zeros(3) |
|
|
|
|
|
self.state = CableState.RELEASING |
|
|
|
|
|
|
|
|
|
|
|
stored_energy = 0.5 * self.spring_k * max(0, self.current_length - self.props.length) ** 2 |
|
|
|
|
|
if whip_prevention: |
|
|
|
|
|
|
|
|
pass |
|
|
|
|
|
self.state = CableState.SEVERED |
|
|
self.current_tension = 0.0 |
|
|
|
|
|
return stored_energy |
|
|
|
|
|
def _trigger_snap(self): |
|
|
"""Handle catastrophic cable failure""" |
|
|
self.state = CableState.SEVERED |
|
|
self.current_tension = 0.0 |
|
|
|
|
|
|
|
|
def get_cable_strain(self) -> float: |
|
|
"""Get current strain (stretch / original length)""" |
|
|
return (self.current_length - self.props.length) / self.props.length |
|
|
|
|
|
def get_cable_stress(self) -> float: |
|
|
"""Get current stress (tension / cross-sectional area)""" |
|
|
cross_section = np.pi * (self.props.diameter / 2) ** 2 |
|
|
return self.current_tension / cross_section |
|
|
|
|
|
|
|
|
class TetherArray: |
|
|
""" |
|
|
Manages the complete array of tether cables for the KAPS system. |
|
|
|
|
|
Handles: |
|
|
- Multi-cable coordination |
|
|
- Tangle detection/prevention |
|
|
- Synchronized release sequences |
|
|
- Formation constraint forces |
|
|
""" |
|
|
|
|
|
def __init__(self, cable_properties: CableProperties, num_cables: int = 4): |
|
|
self.cables: dict[str, TetherConstraint] = {} |
|
|
self.cable_props = cable_properties |
|
|
|
|
|
|
|
|
|
|
|
anchor_offsets = { |
|
|
"UP": np.array([0.0, 0.0, 0.5]), |
|
|
"DOWN": np.array([0.0, 0.0, -0.5]), |
|
|
"LEFT": np.array([0.0, -0.5, 0.0]), |
|
|
"RIGHT": np.array([0.0, 0.5, 0.0]), |
|
|
} |
|
|
|
|
|
for i, (name, offset) in enumerate(anchor_offsets.items()): |
|
|
if i >= num_cables: |
|
|
break |
|
|
self.cables[name] = TetherConstraint( |
|
|
cable_id=name, |
|
|
properties=cable_properties, |
|
|
anchor_point_mother=offset, |
|
|
anchor_point_tab=np.array([0.0, 0.0, 0.0]) |
|
|
) |
|
|
|
|
|
def compute_all_forces(self, |
|
|
mother_pos: np.ndarray, |
|
|
mother_vel: np.ndarray, |
|
|
tab_positions: dict[str, np.ndarray], |
|
|
tab_velocities: dict[str, np.ndarray], |
|
|
dt: float) -> dict: |
|
|
""" |
|
|
Compute tension forces for all cables simultaneously. |
|
|
|
|
|
Returns dict with force data for each cable. |
|
|
""" |
|
|
results = {} |
|
|
total_force_on_mother = np.zeros(3) |
|
|
|
|
|
for cable_id, cable in self.cables.items(): |
|
|
if cable_id not in tab_positions: |
|
|
continue |
|
|
|
|
|
f_mother, f_tab, tension = cable.compute_tension_force( |
|
|
mother_pos, |
|
|
tab_positions[cable_id], |
|
|
mother_vel, |
|
|
tab_velocities.get(cable_id, np.zeros(3)), |
|
|
dt |
|
|
) |
|
|
|
|
|
total_force_on_mother += f_mother |
|
|
|
|
|
results[cable_id] = { |
|
|
"force_on_mother": f_mother, |
|
|
"force_on_tab": f_tab, |
|
|
"tension": tension, |
|
|
"state": cable.state, |
|
|
"length": cable.current_length, |
|
|
"strain": cable.get_cable_strain() |
|
|
} |
|
|
|
|
|
results["total_mother_force"] = total_force_on_mother |
|
|
return results |
|
|
|
|
|
def release_cable(self, cable_id: str) -> bool: |
|
|
"""Release a specific cable by ID""" |
|
|
if cable_id in self.cables: |
|
|
self.cables[cable_id].release() |
|
|
return True |
|
|
return False |
|
|
|
|
|
def release_all(self) -> dict[str, float]: |
|
|
""" |
|
|
Emergency release of all cables. |
|
|
Used for the "speed burst" maneuver. |
|
|
|
|
|
Returns energy released per cable. |
|
|
""" |
|
|
energies = {} |
|
|
for cable_id, cable in self.cables.items(): |
|
|
energies[cable_id] = cable.release() |
|
|
return energies |
|
|
|
|
|
def check_tangle_risk(self, tab_positions: dict[str, np.ndarray]) -> list[Tuple[str, str]]: |
|
|
""" |
|
|
Detect if any cables are at risk of tangling. |
|
|
|
|
|
Returns list of (cable1, cable2) pairs that are too close. |
|
|
""" |
|
|
tangle_pairs = [] |
|
|
cable_ids = list(tab_positions.keys()) |
|
|
|
|
|
for i, id1 in enumerate(cable_ids): |
|
|
for id2 in cable_ids[i+1:]: |
|
|
pos1 = tab_positions[id1] |
|
|
pos2 = tab_positions[id2] |
|
|
|
|
|
|
|
|
distance = np.linalg.norm(pos1 - pos2) |
|
|
safe_distance = self.cable_props.length * 0.1 |
|
|
|
|
|
if distance < safe_distance: |
|
|
tangle_pairs.append((id1, id2)) |
|
|
|
|
|
return tangle_pairs |
|
|
|
|
|
def get_total_drag_contribution(self) -> float: |
|
|
""" |
|
|
Estimate additional drag on mother drone from cable windage. |
|
|
|
|
|
Returns approximate drag force in Newtons. |
|
|
""" |
|
|
total_drag = 0.0 |
|
|
cable_drag_coeff = 1.2 |
|
|
|
|
|
for cable in self.cables.values(): |
|
|
if cable.state == CableState.ATTACHED: |
|
|
|
|
|
|
|
|
|
|
|
airspeed = 50.0 |
|
|
rho = 1.225 |
|
|
area = cable.props.diameter * cable.current_length |
|
|
drag = 0.5 * rho * airspeed**2 * cable_drag_coeff * area |
|
|
total_drag += drag |
|
|
|
|
|
return total_drag |
|
|
|