mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
508 lines
18 KiB
Python
508 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
gwl_oscillator_step2_damped.py
|
||
|
||
STEP 2: Damped Harmonic Oscillator (Attractor Dynamics)
|
||
|
||
Base equation: d²x/dt² + 2ζω₀·dx/dt + ω₀²·x = 0
|
||
or: m·d²x/dt² + γ·dx/dt + k·x = 0
|
||
|
||
Analytic solution depends on damping regime:
|
||
- Underdamped (ζ < 1): x(t) = A·e^(-ζω₀t)·cos(ω₁t + φ), ω₁ = ω₀√(1-ζ²)
|
||
- Critically damped (ζ = 1): x(t) = (A + Bt)·e^(-ω₀t)
|
||
- Overdamped (ζ > 1): x(t) = A·e^(-λ₁t) + B·e^(-λ₂t)
|
||
|
||
New GWL primitives: χ (instability), Γ (strain), regime classification
|
||
"""
|
||
|
||
import numpy as np
|
||
from dataclasses import dataclass
|
||
from typing import Tuple, List, Literal
|
||
import math
|
||
|
||
|
||
@dataclass
|
||
class DampedOscillatorState:
|
||
"""Canonical state for damped oscillator."""
|
||
x: float # Position
|
||
v: float # Velocity
|
||
t: float # Time
|
||
regime: Literal['underdamped', 'critical', 'overdamped'] = 'underdamped'
|
||
|
||
def to_vector(self) -> Tuple[float, float]:
|
||
return (self.x, self.v)
|
||
|
||
|
||
class GWL_DampedOscillator:
|
||
"""
|
||
Damped harmonic oscillator with regime classification.
|
||
|
||
Adds dissipation to Step 1's validated symplectic backbone.
|
||
"""
|
||
|
||
def __init__(self, omega0: float = 1.0, mass: float = 1.0,
|
||
zeta: float = 0.1, dt: float = 0.01):
|
||
"""
|
||
Args:
|
||
omega0: Natural frequency (rad/s)
|
||
mass: Mass (kg)
|
||
zeta: Damping ratio (dimensionless)
|
||
ζ < 1: underdamped (oscillating decay)
|
||
ζ = 1: critically damped (fastest return)
|
||
ζ > 1: overdamped (slow exponential)
|
||
dt: Time step (s)
|
||
"""
|
||
self.omega0 = omega0
|
||
self.mass = mass
|
||
self.zeta = zeta
|
||
self.dt = dt
|
||
|
||
# Derived parameters
|
||
self.k = mass * omega0**2
|
||
self.gamma = 2 * zeta * mass * omega0 # Damping coefficient
|
||
|
||
# Regime classification
|
||
if zeta < 1.0:
|
||
self.regime = 'underdamped'
|
||
self.omega1 = omega0 * math.sqrt(1 - zeta**2) # Damped frequency
|
||
elif abs(zeta - 1.0) < 0.01:
|
||
self.regime = 'critical'
|
||
self.omega1 = 0.0
|
||
else:
|
||
self.regime = 'overdamped'
|
||
# Two real exponents
|
||
self.lambda1 = omega0 * (zeta + math.sqrt(zeta**2 - 1))
|
||
self.lambda2 = omega0 * (zeta - math.sqrt(zeta**2 - 1))
|
||
self.omega1 = 0.0
|
||
|
||
# State
|
||
self.state = DampedOscillatorState(x=1.0, v=0.0, t=0.0, regime=self.regime)
|
||
self.history: List[DampedOscillatorState] = []
|
||
self.energy_history: List[float] = []
|
||
|
||
def initialize(self, x0: float, v0: float):
|
||
"""Set initial conditions."""
|
||
self.state = DampedOscillatorState(x=x0, v=v0, t=0.0, regime=self.regime)
|
||
self.history = []
|
||
self.energy_history = []
|
||
|
||
def energy(self, state: DampedOscillatorState = None) -> float:
|
||
"""Compute total mechanical energy."""
|
||
if state is None:
|
||
state = self.state
|
||
kinetic = 0.5 * self.mass * state.v**2
|
||
potential = 0.5 * self.k * state.x**2
|
||
return kinetic + potential
|
||
|
||
def dissipation_rate(self) -> float:
|
||
"""Instantaneous energy dissipation: dE/dt = -γv²"""
|
||
return -self.gamma * self.state.v**2
|
||
|
||
def quality_factor(self) -> float:
|
||
"""Q factor: Q = ω₀m/γ = 1/(2ζ)"""
|
||
return 1.0 / (2 * self.zeta) if self.zeta > 0 else float('inf')
|
||
|
||
def analytic_solution(self, t: float, x0: float, v0: float) -> Tuple[float, float]:
|
||
"""
|
||
Analytic solution for given damping regime.
|
||
"""
|
||
if self.regime == 'underdamped':
|
||
# x(t) = e^(-ζω₀t) [A·cos(ω₁t) + B·sin(ω₁t)]
|
||
exp_term = math.exp(-self.zeta * self.omega0 * t)
|
||
cos_term = math.cos(self.omega1 * t)
|
||
sin_term = math.sin(self.omega1 * t)
|
||
|
||
A = x0
|
||
B = (v0 + self.zeta * self.omega0 * x0) / self.omega1
|
||
|
||
x = exp_term * (A * cos_term + B * sin_term)
|
||
|
||
# v = dx/dt
|
||
v = exp_term * (-self.zeta * self.omega0 * (A * cos_term + B * sin_term)
|
||
+ self.omega1 * (-A * sin_term + B * cos_term))
|
||
|
||
elif self.regime == 'critical':
|
||
# x(t) = (A + Bt)·e^(-ω₀t)
|
||
exp_term = math.exp(-self.omega0 * t)
|
||
A = x0
|
||
B = v0 + self.omega0 * x0
|
||
|
||
x = (A + B * t) * exp_term
|
||
v = (B - self.omega0 * (A + B * t)) * exp_term
|
||
|
||
else: # overdamped
|
||
# x(t) = A·e^(-λ₁t) + B·e^(-λ₂t)
|
||
exp1 = math.exp(-self.lambda1 * t)
|
||
exp2 = math.exp(-self.lambda2 * t)
|
||
|
||
# Solve for A, B from initial conditions
|
||
# x0 = A + B
|
||
# v0 = -λ₁A - λ₂B
|
||
det = self.lambda2 - self.lambda1
|
||
A = (v0 + self.lambda2 * x0) / det
|
||
B = (-v0 - self.lambda1 * x0) / det
|
||
|
||
x = A * exp1 + B * exp2
|
||
v = -A * self.lambda1 * exp1 - B * self.lambda2 * exp2
|
||
|
||
return x, v
|
||
|
||
def step(self):
|
||
"""
|
||
Symplectic update with dissipation.
|
||
|
||
Split-step method:
|
||
1. Symplectic (conservative) step
|
||
2. Exact dissipation step
|
||
"""
|
||
x_n = self.state.x
|
||
v_n = self.state.v
|
||
|
||
# Step 1: Conservative part (from Step 1, validated)
|
||
v_temp = v_n - self.omega0**2 * x_n * self.dt
|
||
|
||
# Step 2: Dissipation (exact for linear drag)
|
||
# dv/dt = -(γ/m)v → v(t) = v(0)·exp(-γt/m)
|
||
v_new = v_temp * math.exp(-self.gamma * self.dt / self.mass)
|
||
|
||
# Step 3: Position update with new velocity
|
||
x_new = x_n + v_new * self.dt
|
||
|
||
# Update state
|
||
t_new = self.state.t + self.dt
|
||
self.state = DampedOscillatorState(x=x_new, v=v_new, t=t_new, regime=self.regime)
|
||
|
||
# Record
|
||
self.history.append(self.state)
|
||
self.energy_history.append(self.energy())
|
||
|
||
def run(self, steps: int):
|
||
"""Run simulation."""
|
||
for _ in range(steps):
|
||
self.step()
|
||
|
||
def envelope(self, t: float, x0: float, v0: float) -> float:
|
||
"""Decay envelope for underdamped case."""
|
||
if self.regime == 'underdamped':
|
||
# Approximate envelope
|
||
E0 = 0.5 * self.k * x0**2 + 0.5 * self.mass * v0**2
|
||
return math.sqrt(2 * E0 / self.k) * math.exp(-self.zeta * self.omega0 * t)
|
||
return None
|
||
|
||
|
||
class DampedValidationSuite:
|
||
"""
|
||
Validation for Step 2: Damped oscillator.
|
||
|
||
Tests all three damping regimes against analytic solutions.
|
||
"""
|
||
|
||
def __init__(self, omega0: float = 1.0, mass: float = 1.0):
|
||
self.omega0 = omega0
|
||
self.mass = mass
|
||
self.results = {}
|
||
|
||
def test_underdamped_envelope(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 1: Underdamped envelope should decay as e^(-ζω₀t).
|
||
"""
|
||
zeta = 0.1 # Light damping
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=0.01)
|
||
osc.initialize(x0=1.0, v0=0.0)
|
||
|
||
# Run for several time constants
|
||
tau = 1.0 / (zeta * self.omega0) # Decay time constant
|
||
steps = int(5 * tau / 0.01)
|
||
osc.run(steps=steps)
|
||
|
||
# Check envelope decay
|
||
peaks_x = []
|
||
peaks_t = []
|
||
for i in range(1, len(osc.history) - 1):
|
||
h = osc.history[i]
|
||
if h.x > osc.history[i-1].x and h.x > osc.history[i+1].x and h.x > 0:
|
||
peaks_x.append(h.x)
|
||
peaks_t.append(h.t)
|
||
|
||
if len(peaks_x) >= 3:
|
||
# Fit exponential to peaks: x_peak ∝ e^(-ζω₀t)
|
||
log_peaks = np.log(peaks_x)
|
||
coeffs = np.polyfit(peaks_t, log_peaks, 1)
|
||
measured_decay = -coeffs[0]
|
||
expected_decay = zeta * self.omega0
|
||
error = abs(measured_decay - expected_decay) / expected_decay
|
||
else:
|
||
error = float('inf')
|
||
|
||
passed = error < 0.1
|
||
|
||
return passed, {
|
||
'zeta': zeta,
|
||
'measured_decay': measured_decay if 'measured_decay' in dir() else None,
|
||
'expected_decay': expected_decay,
|
||
'error': error,
|
||
'num_peaks': len(peaks_x),
|
||
'threshold': 0.1
|
||
}
|
||
|
||
def test_frequency_shift(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 2: Underdamped frequency should be ω₁ = ω₀√(1-ζ²).
|
||
"""
|
||
zeta = 0.2
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=0.001)
|
||
osc.initialize(x0=0.0, v0=1.0) # Start at origin
|
||
|
||
# Run and measure period
|
||
tau = 1.0 / (zeta * self.omega0)
|
||
steps = int(10 * tau / 0.001) # Several cycles
|
||
osc.run(steps=steps)
|
||
|
||
# Find zero crossings
|
||
x_values = [h.x for h in osc.history]
|
||
t_values = [h.t for h in osc.history]
|
||
|
||
zero_crossings = []
|
||
for i in range(1, len(x_values)):
|
||
if x_values[i-1] < 0 and x_values[i] >= 0:
|
||
t_cross = t_values[i-1] + (t_values[i] - t_values[i-1]) * abs(x_values[i-1]) / (abs(x_values[i-1]) + abs(x_values[i]))
|
||
zero_crossings.append(t_cross)
|
||
|
||
if len(zero_crossings) >= 4:
|
||
periods = [zero_crossings[i] - zero_crossings[i-1] for i in range(2, len(zero_crossings))]
|
||
T_measured = np.mean(periods)
|
||
omega_measured = 2 * math.pi / T_measured
|
||
omega_expected = self.omega0 * math.sqrt(1 - zeta**2)
|
||
error = abs(omega_measured - omega_expected) / omega_expected
|
||
else:
|
||
error = float('inf')
|
||
omega_measured = None
|
||
omega_expected = self.omega0 * math.sqrt(1 - zeta**2)
|
||
|
||
passed = error < 0.05
|
||
|
||
return passed, {
|
||
'zeta': zeta,
|
||
'omega_measured': omega_measured,
|
||
'omega_expected': omega_expected,
|
||
'error': error,
|
||
'threshold': 0.05
|
||
}
|
||
|
||
def test_critical_damping(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 3: Critical damping (ζ=1) should return to zero fastest.
|
||
"""
|
||
x0, v0 = 1.0, 0.0
|
||
|
||
# Test three damping values
|
||
results = {}
|
||
for zeta in [0.5, 1.0, 2.0]:
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=0.01)
|
||
osc.initialize(x0=x0, v0=v0)
|
||
|
||
# Find time to reach |x| < 0.01
|
||
target_steps = 2000
|
||
osc.run(steps=target_steps)
|
||
|
||
# Find settling time
|
||
settling_time = None
|
||
for i, h in enumerate(osc.history):
|
||
if abs(h.x) < 0.01 and abs(h.v) < 0.01:
|
||
settling_time = h.t
|
||
break
|
||
|
||
results[zeta] = settling_time
|
||
|
||
# Critical (ζ=1) should be fastest or nearly so
|
||
crit_time = results[1.0]
|
||
under_time = results[0.5]
|
||
over_time = results[2.0]
|
||
|
||
# Critical should beat overdamped
|
||
passed = crit_time is not None and (over_time is None or crit_time <= over_time)
|
||
|
||
return passed, {
|
||
'settling_underdamped': under_time,
|
||
'settling_critical': crit_time,
|
||
'settling_overdamped': over_time,
|
||
'critical_best': crit_time <= over_time if (crit_time and over_time) else None
|
||
}
|
||
|
||
def test_energy_decay(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 4: Energy should decay monotonically (dE/dt = -γv² ≤ 0).
|
||
"""
|
||
zeta = 0.3
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=0.01)
|
||
osc.initialize(x0=1.0, v0=0.0)
|
||
osc.run(steps=500)
|
||
|
||
# Check monotonic decay
|
||
monotonic = all(osc.energy_history[i] >= osc.energy_history[i+1]
|
||
for i in range(len(osc.energy_history)-1))
|
||
|
||
# Check approximate exponential decay of energy
|
||
E0 = osc.energy_history[0]
|
||
E_values = np.array(osc.energy_history)
|
||
t_values = np.array([h.t for h in osc.history])
|
||
|
||
# Fit: E(t) ≈ E₀·e^(-2ζω₀t) for light damping
|
||
log_E = np.log(E_values / E0)
|
||
valid = log_E > -10 # Avoid log(0)
|
||
if np.sum(valid) > 10:
|
||
coeffs = np.polyfit(t_values[valid], log_E[valid], 1)
|
||
measured_decay = -coeffs[0]
|
||
expected_decay = 2 * zeta * self.omega0
|
||
decay_error = abs(measured_decay - expected_decay) / expected_decay
|
||
else:
|
||
decay_error = float('inf')
|
||
|
||
passed = monotonic and decay_error < 0.3
|
||
|
||
return passed, {
|
||
'monotonic': monotonic,
|
||
'measured_decay': measured_decay if 'measured_decay' in dir() else None,
|
||
'expected_decay': expected_decay if 'expected_decay' in dir() else None,
|
||
'decay_error': decay_error if 'decay_error' in dir() else None
|
||
}
|
||
|
||
def test_analytic_agreement(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 5: Numerical solution matches analytic for all regimes.
|
||
"""
|
||
x0, v0 = 1.0, 0.5
|
||
dt = 0.001
|
||
steps = 500
|
||
|
||
max_errors = {}
|
||
for zeta, regime_name in [(0.1, 'underdamped'), (1.0, 'critical'), (2.0, 'overdamped')]:
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=dt)
|
||
osc.initialize(x0=x0, v0=v0)
|
||
osc.run(steps=steps)
|
||
|
||
max_error = 0.0
|
||
for state in osc.history:
|
||
x_analytic, v_analytic = osc.analytic_solution(state.t, x0, v0)
|
||
error = abs(state.x - x_analytic)
|
||
max_error = max(max_error, error)
|
||
|
||
max_errors[regime_name] = max_error
|
||
|
||
# All should have reasonable error
|
||
passed = all(err < 0.1 for err in max_errors.values())
|
||
|
||
return passed, {
|
||
'max_error_underdamped': max_errors['underdamped'],
|
||
'max_error_critical': max_errors['critical'],
|
||
'max_error_overdamped': max_errors['overdamped']
|
||
}
|
||
|
||
def test_regime_classification(self) -> Tuple[bool, dict]:
|
||
"""
|
||
Test 6: System correctly identifies its regime.
|
||
"""
|
||
tests = [
|
||
(0.05, 'underdamped'),
|
||
(0.5, 'underdamped'),
|
||
(0.99, 'underdamped'), # Close to critical
|
||
(1.0, 'critical'),
|
||
(1.5, 'overdamped'),
|
||
(3.0, 'overdamped'),
|
||
]
|
||
|
||
correct = 0
|
||
for zeta, expected in tests:
|
||
osc = GWL_DampedOscillator(omega0=self.omega0, mass=self.mass, zeta=zeta, dt=0.01)
|
||
if osc.regime == expected:
|
||
correct += 1
|
||
|
||
passed = correct == len(tests)
|
||
|
||
return passed, {
|
||
'correct_classifications': correct,
|
||
'total_tests': len(tests),
|
||
'accuracy': correct / len(tests)
|
||
}
|
||
|
||
def run_all(self):
|
||
"""Run complete validation suite."""
|
||
print("=" * 80)
|
||
print("STEP 2 VALIDATION: DAMPED HARMONIC OSCILLATOR")
|
||
print("=" * 80)
|
||
print(f"Base equation: d²x/dt² + 2ζω₀·dx/dt + ω₀²·x = 0")
|
||
print(f"Analytic solutions by regime:")
|
||
print(f" Underdamped (ζ<1): x(t) = A·e^(-ζω₀t)·cos(ω₁t + φ)")
|
||
print(f" Critical (ζ=1): x(t) = (A+Bt)·e^(-ω₀t)")
|
||
print(f" Overdamped (ζ>1): x(t) = A·e^(-λ₁t) + B·e^(-λ₂t)")
|
||
print(f"Parameters: ω₀={self.omega0}, m={self.mass}")
|
||
print()
|
||
|
||
tests = [
|
||
('Underdamped Envelope', self.test_underdamped_envelope),
|
||
('Frequency Shift', self.test_frequency_shift),
|
||
('Critical Damping', self.test_critical_damping),
|
||
('Energy Decay', self.test_energy_decay),
|
||
('Analytic Agreement', self.test_analytic_agreement),
|
||
('Regime Classification', self.test_regime_classification),
|
||
]
|
||
|
||
all_passed = True
|
||
for name, test_fn in tests:
|
||
print(f"\n[Test] {name}")
|
||
print("-" * 60)
|
||
try:
|
||
passed, details = test_fn()
|
||
status = "✓ PASS" if passed else "✗ FAIL"
|
||
print(f"Status: {status}")
|
||
for key, val in details.items():
|
||
if isinstance(val, float):
|
||
print(f" {key}: {val:.6f}")
|
||
elif val is None:
|
||
print(f" {key}: None")
|
||
else:
|
||
print(f" {key}: {val}")
|
||
self.results[name] = {'passed': passed, 'details': details}
|
||
all_passed = all_passed and passed
|
||
except Exception as e:
|
||
print(f"Status: ✗ ERROR - {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self.results[name] = {'passed': False, 'error': str(e)}
|
||
all_passed = False
|
||
|
||
# Summary
|
||
print("\n" + "=" * 80)
|
||
print("SUMMARY")
|
||
print("=" * 80)
|
||
for name, result in self.results.items():
|
||
status = "✓ PASS" if result.get('passed') else "✗ FAIL"
|
||
print(f"{name:30s}: {status}")
|
||
|
||
print("\n" + "=" * 80)
|
||
if all_passed:
|
||
print("ALL TESTS PASSED - STEP 2 VALIDATED")
|
||
print("=" * 80)
|
||
print("""
|
||
The damped harmonic oscillator is now validated.
|
||
Properties verified:
|
||
✓ Underdamped envelope decay (e^(-ζω₀t))
|
||
✓ Frequency shift (ω₁ = ω₀√(1-ζ²))
|
||
✓ Critical damping (fastest return)
|
||
✓ Energy decay monotonic
|
||
✓ Analytic agreement all regimes
|
||
✓ Regime classification correct
|
||
|
||
READY FOR STEP 3: Add external forcing (resonance)
|
||
""")
|
||
else:
|
||
print("SOME TESTS FAILED - DO NOT PROCEED")
|
||
print("=" * 80)
|
||
|
||
return all_passed
|
||
|
||
|
||
if __name__ == "__main__":
|
||
validator = DampedValidationSuite(omega0=1.0, mass=1.0)
|
||
success = validator.run_all()
|
||
exit(0 if success else 1)
|