1use serde::{Deserialize, Serialize};
13use std::collections::VecDeque;
14use thiserror::Error;
15
16#[derive(Debug, Error)]
18pub enum AttractorError {
19 #[error("Insufficient data: need at least {0} points")]
20 InsufficientData(usize),
21
22 #[error("Invalid dimension: {0}")]
23 InvalidDimension(usize),
24
25 #[error("Computation error: {0}")]
26 ComputationError(String),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum AttractorType {
32 PointAttractor,
34 LimitCycle,
36 StrangeAttractor,
38 Unknown,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PhasePoint {
45 pub coordinates: Vec<f64>,
46 pub timestamp: u64,
47}
48
49impl PhasePoint {
50 pub fn new(coordinates: Vec<f64>, timestamp: u64) -> Self {
51 Self {
52 coordinates,
53 timestamp,
54 }
55 }
56
57 pub fn dimension(&self) -> usize {
58 self.coordinates.len()
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Trajectory {
65 pub points: VecDeque<PhasePoint>,
66 pub max_length: usize,
67}
68
69impl Trajectory {
70 pub fn new(max_length: usize) -> Self {
71 Self {
72 points: VecDeque::new(),
73 max_length,
74 }
75 }
76
77 pub fn push(&mut self, point: PhasePoint) {
78 if self.points.len() >= self.max_length {
79 self.points.pop_front();
80 }
81 self.points.push_back(point);
82 }
83
84 pub fn len(&self) -> usize {
85 self.points.len()
86 }
87
88 pub fn is_empty(&self) -> bool {
89 self.points.is_empty()
90 }
91
92 pub fn clear(&mut self) {
93 self.points.clear();
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AttractorInfo {
100 pub attractor_type: AttractorType,
101 pub dimension: usize,
102 pub lyapunov_exponents: Vec<f64>,
103 pub is_stable: bool,
104 pub confidence: f64,
105}
106
107impl AttractorInfo {
108 pub fn is_chaotic(&self) -> bool {
109 matches!(self.attractor_type, AttractorType::StrangeAttractor)
110 }
111
112 pub fn max_lyapunov_exponent(&self) -> Option<f64> {
113 self.lyapunov_exponents
114 .iter()
115 .copied()
116 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BehaviorSummary {
123 pub total_points: usize,
124 pub dimension: usize,
125 pub attractor_info: Option<AttractorInfo>,
126 pub mean_velocity: f64,
127 pub trajectory_length: f64,
128}
129
130pub struct AttractorAnalyzer {
132 embedding_dimension: usize,
133 min_points_for_analysis: usize,
134 trajectory: Trajectory,
135}
136
137impl AttractorAnalyzer {
138 pub fn new(embedding_dimension: usize, max_trajectory_length: usize) -> Self {
140 Self {
141 embedding_dimension,
142 min_points_for_analysis: 100,
143 trajectory: Trajectory::new(max_trajectory_length),
144 }
145 }
146
147 pub fn add_point(&mut self, point: PhasePoint) -> Result<(), AttractorError> {
149 if point.dimension() != self.embedding_dimension {
150 return Err(AttractorError::InvalidDimension(point.dimension()));
151 }
152
153 self.trajectory.push(point);
154 Ok(())
155 }
156
157 pub fn analyze(&self) -> Result<AttractorInfo, AttractorError> {
159 if self.trajectory.len() < self.min_points_for_analysis {
160 return Err(AttractorError::InsufficientData(
161 self.min_points_for_analysis,
162 ));
163 }
164
165 let lyapunov_exponents = self.calculate_lyapunov_exponents()?;
167
168 let attractor_type = self.classify_attractor(&lyapunov_exponents);
170
171 let is_stable = lyapunov_exponents.iter().all(|&l| l < 0.0);
173
174 let confidence = self.calculate_confidence();
176
177 Ok(AttractorInfo {
178 attractor_type,
179 dimension: self.embedding_dimension,
180 lyapunov_exponents,
181 is_stable,
182 confidence,
183 })
184 }
185
186 fn calculate_lyapunov_exponents(&self) -> Result<Vec<f64>, AttractorError> {
188 if self.trajectory.len() < 2 {
189 return Ok(vec![0.0; self.embedding_dimension]);
190 }
191
192 let mut exponents = vec![0.0; self.embedding_dimension];
193
194 let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
197
198 #[allow(clippy::needless_range_loop)]
202 for dim in 0..self.embedding_dimension {
203 let mut sum_log_divergence = 0.0;
204 let mut count = 0;
205
206 for i in 1..points.len() {
207 let diff = points[i].coordinates[dim] - points[i - 1].coordinates[dim];
208 if diff.abs() > 1e-10 {
209 sum_log_divergence += diff.abs().ln();
210 count += 1;
211 }
212 }
213
214 if count > 0 {
215 exponents[dim] = sum_log_divergence / count as f64;
216 }
217 }
218
219 Ok(exponents)
220 }
221
222 fn classify_attractor(&self, lyapunov_exponents: &[f64]) -> AttractorType {
224 let max_exponent = lyapunov_exponents
225 .iter()
226 .copied()
227 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
228 .unwrap_or(0.0);
229
230 if max_exponent > 0.1 {
231 AttractorType::StrangeAttractor
233 } else if max_exponent > -0.1 && self.detect_periodicity() {
234 AttractorType::LimitCycle
236 } else if max_exponent < -0.1 {
237 AttractorType::PointAttractor
239 } else {
240 AttractorType::Unknown
241 }
242 }
243
244 fn detect_periodicity(&self) -> bool {
246 if self.trajectory.len() < 20 {
247 return false;
248 }
249
250 let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
252 let n = points.len();
253
254 for lag in 5..n / 4 {
256 let mut correlation = 0.0;
257 let mut count = 0;
258
259 for i in 0..n - lag {
260 for dim in 0..self.embedding_dimension {
261 let diff =
262 (points[i].coordinates[dim] - points[i + lag].coordinates[dim]).abs();
263 correlation += diff;
264 count += 1;
265 }
266 }
267
268 let avg_diff = correlation / count as f64;
269 if avg_diff < 0.1 {
270 return true; }
272 }
273
274 false
275 }
276
277 fn calculate_confidence(&self) -> f64 {
279 let data_ratio = self.trajectory.len() as f64 / self.min_points_for_analysis as f64;
280 data_ratio.min(1.0)
281 }
282
283 pub fn get_trajectory_stats(&self) -> BehaviorSummary {
285 let total_points = self.trajectory.len();
286
287 let mut trajectory_length = 0.0;
288 let mut velocity_sum = 0.0;
289
290 let points: Vec<&PhasePoint> = self.trajectory.points.iter().collect();
291
292 for i in 1..points.len() {
293 let mut distance = 0.0;
294 for dim in 0..self.embedding_dimension {
295 let diff = points[i].coordinates[dim] - points[i - 1].coordinates[dim];
296 distance += diff * diff;
297 }
298 let segment_length = distance.sqrt();
299 trajectory_length += segment_length;
300
301 let time_diff = (points[i].timestamp - points[i - 1].timestamp) as f64;
302 if time_diff > 0.0 {
303 velocity_sum += segment_length / time_diff;
304 }
305 }
306
307 let mean_velocity = if points.len() > 1 {
308 velocity_sum / (points.len() - 1) as f64
309 } else {
310 0.0
311 };
312
313 let attractor_info = if total_points >= self.min_points_for_analysis {
314 self.analyze().ok()
315 } else {
316 None
317 };
318
319 BehaviorSummary {
320 total_points,
321 dimension: self.embedding_dimension,
322 attractor_info,
323 mean_velocity,
324 trajectory_length,
325 }
326 }
327
328 pub fn clear(&mut self) {
330 self.trajectory.clear();
331 }
332
333 pub fn trajectory_length(&self) -> usize {
335 self.trajectory.len()
336 }
337}
338
339impl Default for AttractorAnalyzer {
340 fn default() -> Self {
341 Self::new(3, 10000)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_phase_point() {
351 let point = PhasePoint::new(vec![1.0, 2.0, 3.0], 100);
352 assert_eq!(point.dimension(), 3);
353 }
354
355 #[test]
356 fn test_trajectory() {
357 let mut traj = Trajectory::new(10);
358 assert!(traj.is_empty());
359
360 traj.push(PhasePoint::new(vec![1.0, 2.0], 1));
361 assert_eq!(traj.len(), 1);
362
363 for i in 2..=11 {
365 traj.push(PhasePoint::new(vec![i as f64, i as f64 * 2.0], i as u64));
366 }
367
368 assert_eq!(traj.len(), 10);
370 }
371
372 #[test]
373 fn test_attractor_analyzer() {
374 let mut analyzer = AttractorAnalyzer::new(2, 1000);
375
376 for i in 0..150 {
378 let point = PhasePoint::new(vec![i as f64, (i * 2) as f64], i as u64 * 1000);
379 analyzer.add_point(point).unwrap();
380 }
381
382 assert_eq!(analyzer.trajectory_length(), 150);
383
384 let result = analyzer.analyze();
385 assert!(result.is_ok());
386
387 let info = result.unwrap();
388 assert_eq!(info.dimension, 2);
389 assert!(!info.lyapunov_exponents.is_empty());
390 }
391
392 #[test]
393 fn test_invalid_dimension() {
394 let mut analyzer = AttractorAnalyzer::new(3, 1000);
395
396 let point = PhasePoint::new(vec![1.0, 2.0], 100); let result = analyzer.add_point(point);
399 assert!(result.is_err());
400 }
401
402 #[test]
403 fn test_insufficient_data() {
404 let analyzer = AttractorAnalyzer::new(2, 1000);
405
406 let result = analyzer.analyze();
408 assert!(result.is_err());
409 }
410
411 #[test]
412 fn test_behavior_summary() {
413 let mut analyzer = AttractorAnalyzer::new(2, 1000);
414
415 for i in 0..50 {
416 let point = PhasePoint::new(vec![i as f64, i as f64], i as u64 * 100);
417 analyzer.add_point(point).unwrap();
418 }
419
420 let summary = analyzer.get_trajectory_stats();
421 assert_eq!(summary.total_points, 50);
422 assert_eq!(summary.dimension, 2);
423 assert!(summary.trajectory_length > 0.0);
424 }
425
426 #[test]
427 fn test_nan_handling_in_lyapunov_exponents() {
428 let info = AttractorInfo {
430 attractor_type: AttractorType::StrangeAttractor,
431 dimension: 3,
432 lyapunov_exponents: vec![1.0, f64::NAN, -0.5],
433 is_stable: false,
434 confidence: 0.95,
435 };
436
437 let max_exp = info.max_lyapunov_exponent();
439 assert!(max_exp.is_some());
440 let val = max_exp.unwrap();
442 assert!(val.is_finite(), "Should not return NaN");
443 }
444
445 #[test]
446 fn test_nan_handling_in_trajectory() {
447 let mut analyzer = AttractorAnalyzer::new(2, 1000);
449
450 for i in 0..150 {
452 let coords = if i == 50 {
453 vec![f64::NAN, i as f64]
455 } else {
456 vec![i as f64, (i * 2) as f64]
457 };
458
459 let point = PhasePoint::new(coords, i as u64 * 1000);
460 let _ = analyzer.add_point(point);
462 }
463
464 let result = analyzer.analyze();
466 assert!(result.is_ok(), "Analysis should handle NaN gracefully");
467
468 let info = result.unwrap();
469 assert_eq!(info.dimension, 2);
471 }
472
473 #[test]
474 fn test_all_nan_lyapunov_exponents() {
475 let info = AttractorInfo {
477 attractor_type: AttractorType::Unknown,
478 dimension: 2,
479 lyapunov_exponents: vec![f64::NAN, f64::NAN],
480 is_stable: false,
481 confidence: 0.5,
482 };
483
484 let max_exp = info.max_lyapunov_exponent();
486 assert!(max_exp.is_some());
487 }
488}