Research-Stack/5-Applications/tools-scripts/demo/gwl_oscillator_step2_damped.py

508 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 = -γ"""
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)