Skip to main content

proof_engine/relativistic/
doppler.rs

1//! Relativistic Doppler effect.
2
3use glam::{Vec3, Vec4};
4use super::lorentz::lorentz_factor;
5
6/// Relativistic longitudinal Doppler effect.
7/// Returns observed frequency given source frequency f_source, speed v, and whether approaching.
8/// f_obs = f_source * sqrt((1+beta)/(1-beta)) for approaching
9/// f_obs = f_source * sqrt((1-beta)/(1+beta)) for receding
10pub fn relativistic_doppler(f_source: f64, v: f64, c: f64, approaching: bool) -> f64 {
11    let beta = (v / c).abs().min(0.9999999);
12    if approaching {
13        f_source * ((1.0 + beta) / (1.0 - beta)).sqrt()
14    } else {
15        f_source * ((1.0 - beta) / (1.0 + beta)).sqrt()
16    }
17}
18
19/// Transverse Doppler effect (purely relativistic, no classical analog).
20/// f_obs = f_source / gamma (always a redshift).
21pub fn transverse_doppler(f_source: f64, v: f64, c: f64) -> f64 {
22    let gamma = lorentz_factor(v, c);
23    f_source / gamma
24}
25
26/// Wavelength shift: returns observed wavelength.
27/// lambda_obs = lambda_source / doppler_factor
28/// Positive v = receding (redshift), negative v = approaching (blueshift).
29pub fn wavelength_shift(lambda_source: f64, v: f64, c: f64) -> f64 {
30    let beta = v / c;
31    let beta_clamped = beta.clamp(-0.9999999, 0.9999999);
32    lambda_source * ((1.0 + beta_clamped) / (1.0 - beta_clamped)).sqrt()
33}
34
35/// Cosmological redshift parameter z.
36/// z = sqrt((1+beta)/(1-beta)) - 1
37pub fn redshift_z(v: f64, c: f64) -> f64 {
38    let beta = (v / c).abs().min(0.9999999);
39    ((1.0 + beta) / (1.0 - beta)).sqrt() - 1.0
40}
41
42/// Convert a wavelength in nanometers to an approximate RGBA color.
43/// Maps the visible spectrum (380-780nm) to RGB.
44/// Outside visible range, returns dim values.
45pub fn color_from_wavelength(wavelength_nm: f64) -> Vec4 {
46    let w = wavelength_nm;
47    let (mut r, mut g, mut b);
48
49    if w < 380.0 {
50        // Ultraviolet - faint violet
51        r = 0.2;
52        g = 0.0;
53        b = 0.3;
54    } else if w < 440.0 {
55        r = -(w - 440.0) / (440.0 - 380.0);
56        g = 0.0;
57        b = 1.0;
58    } else if w < 490.0 {
59        r = 0.0;
60        g = (w - 440.0) / (490.0 - 440.0);
61        b = 1.0;
62    } else if w < 510.0 {
63        r = 0.0;
64        g = 1.0;
65        b = -(w - 510.0) / (510.0 - 490.0);
66    } else if w < 580.0 {
67        r = (w - 510.0) / (580.0 - 510.0);
68        g = 1.0;
69        b = 0.0;
70    } else if w < 645.0 {
71        r = 1.0;
72        g = -(w - 645.0) / (645.0 - 580.0);
73        b = 0.0;
74    } else if w < 780.0 {
75        r = 1.0;
76        g = 0.0;
77        b = 0.0;
78    } else {
79        // Infrared - faint red
80        r = 0.3;
81        g = 0.0;
82        b = 0.0;
83    }
84
85    // Intensity fall-off at edges of visible spectrum
86    let intensity = if w < 380.0 || w > 780.0 {
87        0.3
88    } else if w < 420.0 {
89        0.3 + 0.7 * (w - 380.0) / (420.0 - 380.0)
90    } else if w > 700.0 {
91        0.3 + 0.7 * (780.0 - w) / (780.0 - 700.0)
92    } else {
93        1.0
94    };
95
96    r *= intensity;
97    g *= intensity;
98    b *= intensity;
99
100    Vec4::new(r as f32, g as f32, b as f32, 1.0)
101}
102
103/// Shift a base color by the relativistic Doppler effect.
104/// Uses the radial velocity component (projection of v onto observer direction).
105pub fn doppler_color_shift(
106    base_color: Vec4,
107    v: f64,
108    c: f64,
109    direction: Vec3,
110    observer_dir: Vec3,
111) -> Vec4 {
112    let dir_norm = direction.normalize_or_zero();
113    let obs_norm = observer_dir.normalize_or_zero();
114    // Radial velocity component: positive = receding
115    let v_radial = -(v as f32) * dir_norm.dot(obs_norm);
116    let v_r = v_radial as f64;
117
118    // Estimate a "dominant wavelength" from the color and shift it
119    // Simple approach: shift the color temperature
120    let beta = (v_r / c).clamp(-0.9999999, 0.9999999);
121    let doppler = ((1.0 + beta) / (1.0 - beta)).sqrt();
122
123    // Shift RGB channels: blueshift moves energy up, redshift moves it down
124    let shift = (1.0 / doppler) as f32;
125
126    // Apply shift by interpolating channels
127    if shift > 1.0 {
128        // Blueshift: move red->green->blue
129        let t = (shift - 1.0).min(1.0);
130        Vec4::new(
131            base_color.x * (1.0 - t) + base_color.y * t,
132            base_color.y * (1.0 - t) + base_color.z * t,
133            base_color.z + base_color.x * t,
134            base_color.w,
135        )
136    } else {
137        // Redshift: move blue->green->red
138        let t = (1.0 - shift).min(1.0);
139        Vec4::new(
140            base_color.x + base_color.z * t,
141            base_color.y * (1.0 - t) + base_color.x * t,
142            base_color.z * (1.0 - t) + base_color.y * t,
143            base_color.w,
144        )
145    }
146}
147
148/// Renderer that shifts entity colors based on radial velocity toward observer.
149#[derive(Debug, Clone)]
150pub struct DopplerRenderer {
151    pub c: f64,
152    pub observer_pos: Vec3,
153    pub intensity_shift: bool,
154}
155
156impl DopplerRenderer {
157    pub fn new(c: f64, observer_pos: Vec3) -> Self {
158        Self {
159            c,
160            observer_pos,
161            intensity_shift: true,
162        }
163    }
164
165    /// Compute the Doppler-shifted color for an entity.
166    pub fn shifted_color(
167        &self,
168        base_color: Vec4,
169        entity_pos: Vec3,
170        entity_velocity: Vec3,
171    ) -> Vec4 {
172        let to_observer = (self.observer_pos - entity_pos).normalize_or_zero();
173        let v = entity_velocity.length() as f64;
174        if v < 1e-10 {
175            return base_color;
176        }
177        let direction = entity_velocity.normalize();
178        doppler_color_shift(base_color, v, self.c, direction, to_observer)
179    }
180
181    /// Compute the Doppler frequency ratio (observed/emitted).
182    pub fn frequency_ratio(&self, entity_pos: Vec3, entity_velocity: Vec3) -> f64 {
183        let to_observer = (self.observer_pos - entity_pos).normalize_or_zero();
184        let v_radial = -entity_velocity.dot(to_observer) as f64;
185        let beta = (v_radial / self.c).clamp(-0.9999999, 0.9999999);
186        ((1.0 + beta) / (1.0 - beta)).sqrt()
187    }
188
189    /// Batch process: shift colors for multiple entities.
190    pub fn shift_colors(
191        &self,
192        entities: &[(Vec3, Vec3, Vec4)], // (position, velocity, base_color)
193    ) -> Vec<Vec4> {
194        entities.iter().map(|(pos, vel, col)| {
195            self.shifted_color(*col, *pos, *vel)
196        }).collect()
197    }
198
199    /// Compute intensity scaling from Doppler effect.
200    /// Intensity scales as (f_obs/f_source)^3 for a moving isotropic source.
201    pub fn intensity_factor(&self, entity_pos: Vec3, entity_velocity: Vec3) -> f32 {
202        let ratio = self.frequency_ratio(entity_pos, entity_velocity);
203        (ratio.powi(3)).min(10.0) as f32
204    }
205}
206
207/// Cosmological redshift: shifts wavelength by factor (1 + z).
208pub fn cosmic_redshift(z: f64, base_wavelength: f64) -> f64 {
209    base_wavelength * (1.0 + z)
210}
211
212/// Compute the velocity from redshift z (special relativistic formula).
213pub fn velocity_from_redshift(z: f64, c: f64) -> f64 {
214    let z1 = z + 1.0;
215    c * (z1 * z1 - 1.0) / (z1 * z1 + 1.0)
216}
217
218/// General Doppler formula for arbitrary angle theta between velocity and line of sight.
219/// f_obs = f_source / (gamma * (1 - beta * cos(theta)))
220/// theta = 0 means source moving directly toward observer.
221pub fn general_doppler(f_source: f64, v: f64, c: f64, theta: f64) -> f64 {
222    let beta = v / c;
223    let gamma = lorentz_factor(v, c);
224    f_source / (gamma * (1.0 - beta * theta.cos()))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const C: f64 = 299_792_458.0;
232
233    #[test]
234    fn test_doppler_blueshift() {
235        let f_obs = relativistic_doppler(1000.0, 0.5 * C, C, true);
236        assert!(f_obs > 1000.0, "Approaching should blueshift: {}", f_obs);
237    }
238
239    #[test]
240    fn test_doppler_redshift() {
241        let f_obs = relativistic_doppler(1000.0, 0.5 * C, C, false);
242        assert!(f_obs < 1000.0, "Receding should redshift: {}", f_obs);
243    }
244
245    #[test]
246    fn test_doppler_symmetry() {
247        let f_blue = relativistic_doppler(1000.0, 0.5 * C, C, true);
248        let f_red = relativistic_doppler(1000.0, 0.5 * C, C, false);
249        // f_blue * f_red = f_source^2
250        assert!(
251            (f_blue * f_red - 1000.0 * 1000.0).abs() < 1.0,
252            "Product should equal f^2: {} * {} = {}",
253            f_blue, f_red, f_blue * f_red
254        );
255    }
256
257    #[test]
258    fn test_transverse_doppler_redshift() {
259        let f_obs = transverse_doppler(1000.0, 0.5 * C, C);
260        assert!(f_obs < 1000.0, "Transverse Doppler should always redshift: {}", f_obs);
261    }
262
263    #[test]
264    fn test_transverse_less_than_longitudinal() {
265        let f_trans = transverse_doppler(1000.0, 0.5 * C, C);
266        let f_long = relativistic_doppler(1000.0, 0.5 * C, C, false);
267        // Transverse redshift is less extreme than longitudinal receding
268        assert!(
269            f_trans > f_long,
270            "Transverse {} should be less shifted than longitudinal receding {}",
271            f_trans, f_long
272        );
273    }
274
275    #[test]
276    fn test_wavelength_shift_receding() {
277        let lambda_obs = wavelength_shift(500.0, 0.5 * C, C);
278        assert!(lambda_obs > 500.0, "Receding should increase wavelength: {}", lambda_obs);
279    }
280
281    #[test]
282    fn test_wavelength_shift_approaching() {
283        let lambda_obs = wavelength_shift(500.0, -0.5 * C, C);
284        assert!(lambda_obs < 500.0, "Approaching should decrease wavelength: {}", lambda_obs);
285    }
286
287    #[test]
288    fn test_redshift_z_zero_at_rest() {
289        let z = redshift_z(0.0, C);
290        assert!(z.abs() < 1e-10);
291    }
292
293    #[test]
294    fn test_redshift_z_approaches_infinity() {
295        let z = redshift_z(0.9999 * C, C);
296        assert!(z > 100.0, "z should be very large near c: {}", z);
297    }
298
299    #[test]
300    fn test_color_from_wavelength_visible() {
301        // Red
302        let red = color_from_wavelength(650.0);
303        assert!(red.x > red.y && red.x > red.z);
304        // Green
305        let green = color_from_wavelength(520.0);
306        assert!(green.y > green.x && green.y > green.z);
307        // Blue
308        let blue = color_from_wavelength(460.0);
309        assert!(blue.z > blue.y);
310    }
311
312    #[test]
313    fn test_cosmic_redshift() {
314        let shifted = cosmic_redshift(1.0, 500.0);
315        assert!((shifted - 1000.0).abs() < 1e-10, "z=1 should double wavelength");
316    }
317
318    #[test]
319    fn test_velocity_from_redshift_roundtrip() {
320        let v = 0.6 * C;
321        let z = redshift_z(v, C);
322        let v_back = velocity_from_redshift(z, C);
323        assert!((v - v_back).abs() / v < 1e-6);
324    }
325
326    #[test]
327    fn test_general_doppler_forward() {
328        // theta=0 (approaching) should match relativistic_doppler approaching
329        let f1 = general_doppler(1000.0, 0.5 * C, C, 0.0);
330        let f2 = relativistic_doppler(1000.0, 0.5 * C, C, true);
331        assert!((f1 - f2).abs() < 1e-6);
332    }
333
334    #[test]
335    fn test_general_doppler_transverse() {
336        // theta = pi/2 should match transverse doppler
337        let f1 = general_doppler(1000.0, 0.5 * C, C, std::f64::consts::FRAC_PI_2);
338        let f2 = transverse_doppler(1000.0, 0.5 * C, C);
339        assert!((f1 - f2).abs() < 1e-6);
340    }
341
342    #[test]
343    fn test_doppler_renderer() {
344        let renderer = DopplerRenderer::new(C, Vec3::new(0.0, 0.0, 0.0));
345        let ratio = renderer.frequency_ratio(
346            Vec3::new(10.0, 0.0, 0.0),
347            Vec3::new(-0.5 * C as f32, 0.0, 0.0), // moving toward observer
348        );
349        assert!(ratio > 1.0, "Should be blueshifted: {}", ratio);
350    }
351}