quantrs2_anneal/
visualization.rs

1//! Energy landscape visualization for quantum annealing problems
2//!
3//! This module provides tools for visualizing the energy landscapes
4//! of Ising models and QUBO problems to understand problem difficulty
5//! and solution quality.
6
7use scirs2_core::random::prelude::*;
8use scirs2_core::random::ChaCha8Rng;
9use scirs2_core::random::{Rng, SeedableRng};
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::Write;
13use std::path::Path;
14use thiserror::Error;
15
16use crate::ising::{IsingError, IsingModel};
17use crate::simulator::{AnnealingParams, AnnealingSolution, QuantumAnnealingSimulator};
18
19/// Errors that can occur during visualization
20#[derive(Error, Debug)]
21pub enum VisualizationError {
22    /// Ising model error
23    #[error("Ising error: {0}")]
24    IsingError(#[from] IsingError),
25
26    /// I/O error
27    #[error("I/O error: {0}")]
28    IoError(#[from] std::io::Error),
29
30    /// Invalid parameter
31    #[error("Invalid parameter: {0}")]
32    InvalidParameter(String),
33
34    /// Computation error
35    #[error("Computation error: {0}")]
36    ComputationError(String),
37}
38
39/// Result type for visualization operations
40pub type VisualizationResult<T> = Result<T, VisualizationError>;
41
42/// Energy landscape data point
43#[derive(Debug, Clone)]
44pub struct LandscapePoint {
45    /// Configuration (spin or binary variables)
46    pub configuration: Vec<i8>,
47
48    /// Energy of this configuration
49    pub energy: f64,
50
51    /// Hamming distance from a reference configuration
52    pub hamming_distance: Option<usize>,
53
54    /// Basin index (if clustering is applied)
55    pub basin_id: Option<usize>,
56}
57
58/// Energy landscape analyzer
59pub struct LandscapeAnalyzer {
60    /// Reference configuration for distance calculations
61    reference_config: Option<Vec<i8>>,
62
63    /// Energy cutoff for sampling
64    energy_cutoff: Option<f64>,
65
66    /// Maximum number of samples
67    max_samples: usize,
68
69    /// Random seed for reproducibility
70    seed: Option<u64>,
71}
72
73impl LandscapeAnalyzer {
74    /// Create a new landscape analyzer
75    #[must_use]
76    pub const fn new() -> Self {
77        Self {
78            reference_config: None,
79            energy_cutoff: None,
80            max_samples: 10_000,
81            seed: None,
82        }
83    }
84
85    /// Set reference configuration for distance calculations
86    #[must_use]
87    pub fn with_reference(mut self, config: Vec<i8>) -> Self {
88        self.reference_config = Some(config);
89        self
90    }
91
92    /// Set energy cutoff for sampling
93    #[must_use]
94    pub const fn with_energy_cutoff(mut self, cutoff: f64) -> Self {
95        self.energy_cutoff = Some(cutoff);
96        self
97    }
98
99    /// Set maximum number of samples
100    #[must_use]
101    pub const fn with_max_samples(mut self, max_samples: usize) -> Self {
102        self.max_samples = max_samples;
103        self
104    }
105
106    /// Set random seed
107    #[must_use]
108    pub const fn with_seed(mut self, seed: u64) -> Self {
109        self.seed = Some(seed);
110        self
111    }
112
113    /// Sample energy landscape using random sampling
114    pub fn sample_landscape(&self, model: &IsingModel) -> VisualizationResult<Vec<LandscapePoint>> {
115        if model.num_qubits > 20 {
116            return Err(VisualizationError::InvalidParameter(
117                "Random sampling only practical for problems with ≤20 qubits".to_string(),
118            ));
119        }
120
121        let mut rng = match self.seed {
122            Some(seed) => ChaCha8Rng::seed_from_u64(seed),
123            None => ChaCha8Rng::seed_from_u64(thread_rng().gen()),
124        };
125
126        let mut points = Vec::new();
127        let num_samples = std::cmp::min(self.max_samples, 2_usize.pow(model.num_qubits as u32));
128
129        for _ in 0..num_samples {
130            let config: Vec<i8> = (0..model.num_qubits)
131                .map(|_| if rng.gen_bool(0.5) { 1 } else { -1 })
132                .collect();
133
134            let energy = model.energy(&config)?;
135
136            // Skip if energy exceeds cutoff
137            if let Some(cutoff) = self.energy_cutoff {
138                if energy > cutoff {
139                    continue;
140                }
141            }
142
143            let hamming_distance = self
144                .reference_config
145                .as_ref()
146                .map(|ref_config| hamming_distance(&config, ref_config));
147
148            points.push(LandscapePoint {
149                configuration: config,
150                energy,
151                hamming_distance,
152                basin_id: None,
153            });
154        }
155
156        Ok(points)
157    }
158
159    /// Exhaustively enumerate energy landscape (small problems only)
160    pub fn enumerate_landscape(
161        &self,
162        model: &IsingModel,
163    ) -> VisualizationResult<Vec<LandscapePoint>> {
164        if model.num_qubits > 16 {
165            return Err(VisualizationError::InvalidParameter(
166                "Exhaustive enumeration only practical for problems with ≤16 qubits".to_string(),
167            ));
168        }
169
170        let mut points = Vec::new();
171        let total_configs = 2_usize.pow(model.num_qubits as u32);
172
173        for i in 0..total_configs {
174            let config: Vec<i8> = (0..model.num_qubits)
175                .map(|j| if (i >> j) & 1 == 1 { 1 } else { -1 })
176                .collect();
177
178            let energy = model.energy(&config)?;
179
180            // Skip if energy exceeds cutoff
181            if let Some(cutoff) = self.energy_cutoff {
182                if energy > cutoff {
183                    continue;
184                }
185            }
186
187            let hamming_distance = self
188                .reference_config
189                .as_ref()
190                .map(|ref_config| hamming_distance(&config, ref_config));
191
192            points.push(LandscapePoint {
193                configuration: config,
194                energy,
195                hamming_distance,
196                basin_id: None,
197            });
198        }
199
200        Ok(points)
201    }
202
203    /// Sample landscape using annealing trajectories
204    pub fn sample_from_annealing(
205        &self,
206        model: &IsingModel,
207        num_trajectories: usize,
208    ) -> VisualizationResult<Vec<LandscapePoint>> {
209        let mut all_points = Vec::new();
210
211        for i in 0..num_trajectories {
212            let params = AnnealingParams {
213                seed: self.seed.map(|s| s + i as u64),
214                num_sweeps: 1000,
215                num_repetitions: 1,
216                ..Default::default()
217            };
218
219            let mut simulator = QuantumAnnealingSimulator::new(params)
220                .map_err(|e| VisualizationError::ComputationError(e.to_string()))?;
221
222            // Collect intermediate states during annealing
223            let trajectory = self.collect_annealing_trajectory(&mut simulator, model)?;
224
225            all_points.extend(trajectory);
226        }
227
228        Ok(all_points)
229    }
230
231    /// Collect trajectory points during annealing
232    fn collect_annealing_trajectory(
233        &self,
234        _simulator: &mut QuantumAnnealingSimulator,
235        model: &IsingModel,
236    ) -> VisualizationResult<Vec<LandscapePoint>> {
237        // For now, generate a simple trajectory
238        // In a real implementation, this would collect intermediate states
239        let mut rng = ChaCha8Rng::seed_from_u64(self.seed.unwrap_or(0));
240        let mut points = Vec::new();
241
242        let mut current_config: Vec<i8> = (0..model.num_qubits)
243            .map(|_| if rng.gen_bool(0.5) { 1 } else { -1 })
244            .collect();
245
246        for step in 0..100 {
247            let energy = model.energy(&current_config)?;
248
249            let hamming_distance = self
250                .reference_config
251                .as_ref()
252                .map(|ref_config| hamming_distance(&current_config, ref_config));
253
254            points.push(LandscapePoint {
255                configuration: current_config.clone(),
256                energy,
257                hamming_distance,
258                basin_id: None,
259            });
260
261            // Simple random walk for demonstration
262            if step % 10 == 0 {
263                let flip_idx = rng.gen_range(0..model.num_qubits);
264                current_config[flip_idx] *= -1;
265            }
266        }
267
268        Ok(points)
269    }
270}
271
272impl Default for LandscapeAnalyzer {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278/// Energy landscape statistics
279#[derive(Debug, Clone)]
280pub struct LandscapeStats {
281    /// Number of configurations sampled
282    pub num_configurations: usize,
283
284    /// Minimum energy found
285    pub min_energy: f64,
286
287    /// Maximum energy found
288    pub max_energy: f64,
289
290    /// Average energy
291    pub mean_energy: f64,
292
293    /// Energy standard deviation
294    pub energy_std: f64,
295
296    /// Number of local minima
297    pub num_local_minima: usize,
298
299    /// Energy gap to first excited state
300    pub energy_gap: Option<f64>,
301
302    /// Degeneracy of ground state
303    pub ground_state_degeneracy: usize,
304}
305
306/// Calculate landscape statistics
307pub fn calculate_landscape_stats(points: &[LandscapePoint]) -> LandscapeStats {
308    if points.is_empty() {
309        return LandscapeStats {
310            num_configurations: 0,
311            min_energy: 0.0,
312            max_energy: 0.0,
313            mean_energy: 0.0,
314            energy_std: 0.0,
315            num_local_minima: 0,
316            energy_gap: None,
317            ground_state_degeneracy: 0,
318        };
319    }
320
321    let energies: Vec<f64> = points.iter().map(|p| p.energy).collect();
322
323    let min_energy = energies.iter().copied().fold(f64::INFINITY, f64::min);
324    let max_energy = energies.iter().copied().fold(f64::NEG_INFINITY, f64::max);
325    let mean_energy = energies.iter().sum::<f64>() / energies.len() as f64;
326
327    let variance = energies
328        .iter()
329        .map(|&e| (e - mean_energy).powi(2))
330        .sum::<f64>()
331        / energies.len() as f64;
332    let energy_std = variance.sqrt();
333
334    // Find unique energies and count degeneracies
335    let mut energy_counts = HashMap::new();
336    for &energy in &energies {
337        *energy_counts.entry(OrderedFloat(energy)).or_insert(0) += 1;
338    }
339
340    let ground_state_degeneracy = *energy_counts.get(&OrderedFloat(min_energy)).unwrap_or(&0);
341
342    // Find energy gap
343    let mut unique_energies: Vec<f64> = energy_counts.keys().map(|&OrderedFloat(e)| e).collect();
344    unique_energies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
345
346    let energy_gap = (unique_energies.len() > 1).then(|| unique_energies[1] - unique_energies[0]);
347
348    LandscapeStats {
349        num_configurations: points.len(),
350        min_energy,
351        max_energy,
352        mean_energy,
353        energy_std,
354        num_local_minima: 0, // Would require neighbor analysis
355        energy_gap,
356        ground_state_degeneracy,
357    }
358}
359
360/// Plot energy landscape to file
361pub fn plot_energy_landscape<P: AsRef<Path>>(
362    points: &[LandscapePoint],
363    output_path: P,
364    title: &str,
365) -> VisualizationResult<()> {
366    let mut file = File::create(output_path)?;
367
368    // Write CSV header
369    writeln!(file, "configuration_id,energy,hamming_distance,basin_id")?;
370
371    // Write data points
372    for (i, point) in points.iter().enumerate() {
373        writeln!(
374            file,
375            "{},{},{},{}",
376            i,
377            point.energy,
378            point
379                .hamming_distance
380                .map_or(String::new(), |d| d.to_string()),
381            point.basin_id.map_or(String::new(), |b| b.to_string())
382        )?;
383    }
384
385    // Write metadata as comments
386    writeln!(file, "# Title: {title}")?;
387    writeln!(file, "# Number of points: {}", points.len())?;
388
389    if !points.is_empty() {
390        let stats = calculate_landscape_stats(points);
391        writeln!(file, "# Min energy: {:.6}", stats.min_energy)?;
392        writeln!(file, "# Max energy: {:.6}", stats.max_energy)?;
393        writeln!(file, "# Mean energy: {:.6}", stats.mean_energy)?;
394        writeln!(file, "# Energy std: {:.6}", stats.energy_std)?;
395    }
396
397    Ok(())
398}
399
400/// Plot energy distribution histogram
401pub fn plot_energy_histogram<P: AsRef<Path>>(
402    points: &[LandscapePoint],
403    output_path: P,
404    num_bins: usize,
405) -> VisualizationResult<()> {
406    if points.is_empty() {
407        return Err(VisualizationError::InvalidParameter(
408            "No points to plot".to_string(),
409        ));
410    }
411
412    let energies: Vec<f64> = points.iter().map(|p| p.energy).collect();
413    let min_energy = energies.iter().copied().fold(f64::INFINITY, f64::min);
414    let max_energy = energies.iter().copied().fold(f64::NEG_INFINITY, f64::max);
415
416    if min_energy == max_energy {
417        return Err(VisualizationError::InvalidParameter(
418            "All energies are the same".to_string(),
419        ));
420    }
421
422    let bin_width = (max_energy - min_energy) / num_bins as f64;
423    let mut bins = vec![0; num_bins];
424
425    for &energy in &energies {
426        let bin_idx = ((energy - min_energy) / bin_width).floor() as usize;
427        let bin_idx = std::cmp::min(bin_idx, num_bins - 1);
428        bins[bin_idx] += 1;
429    }
430
431    let mut file = File::create(output_path)?;
432    writeln!(file, "bin_center,count,frequency")?;
433
434    for (i, &count) in bins.iter().enumerate() {
435        let bin_center = (i as f64 + 0.5).mul_add(bin_width, min_energy);
436        let frequency = f64::from(count) / energies.len() as f64;
437        writeln!(file, "{bin_center:.6},{count},{frequency:.6}")?;
438    }
439
440    Ok(())
441}
442
443/// Calculate Hamming distance between two configurations
444fn hamming_distance(config1: &[i8], config2: &[i8]) -> usize {
445    config1
446        .iter()
447        .zip(config2.iter())
448        .filter(|(&a, &b)| a != b)
449        .count()
450}
451
452/// Wrapper for f64 to make it hashable and orderable
453#[derive(Debug, Clone, Copy, PartialEq)]
454struct OrderedFloat(f64);
455
456impl Eq for OrderedFloat {}
457
458impl std::hash::Hash for OrderedFloat {
459    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
460        self.0.to_bits().hash(state);
461    }
462}
463
464impl std::cmp::Ord for OrderedFloat {
465    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
466        self.0
467            .partial_cmp(&other.0)
468            .unwrap_or(std::cmp::Ordering::Equal)
469    }
470}
471
472impl std::cmp::PartialOrd for OrderedFloat {
473    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
474        Some(self.cmp(other))
475    }
476}
477
478/// Basin analysis for energy landscape
479pub struct BasinAnalyzer {
480    /// Energy tolerance for grouping states into basins
481    energy_tolerance: f64,
482
483    /// Hamming distance threshold for connectivity
484    hamming_threshold: usize,
485}
486
487impl BasinAnalyzer {
488    /// Create a new basin analyzer
489    #[must_use]
490    pub const fn new(energy_tolerance: f64, hamming_threshold: usize) -> Self {
491        Self {
492            energy_tolerance,
493            hamming_threshold,
494        }
495    }
496
497    /// Identify energy basins in the landscape
498    pub fn identify_basins(&self, points: &mut [LandscapePoint]) -> VisualizationResult<usize> {
499        if points.is_empty() {
500            return Ok(0);
501        }
502
503        let mut basin_id = 0;
504
505        for i in 0..points.len() {
506            if points[i].basin_id.is_some() {
507                continue;
508            }
509
510            // Start a new basin
511            points[i].basin_id = Some(basin_id);
512            let mut stack = vec![i];
513
514            while let Some(current_idx) = stack.pop() {
515                let current_energy = points[current_idx].energy;
516                let current_config = points[current_idx].configuration.clone();
517
518                // Find neighbors
519                for j in 0..points.len() {
520                    if points[j].basin_id.is_some() {
521                        continue;
522                    }
523
524                    let energy_diff = (points[j].energy - current_energy).abs();
525                    let hamming_dist = hamming_distance(&current_config, &points[j].configuration);
526
527                    if energy_diff <= self.energy_tolerance
528                        && hamming_dist <= self.hamming_threshold
529                    {
530                        points[j].basin_id = Some(basin_id);
531                        stack.push(j);
532                    }
533                }
534            }
535
536            basin_id += 1;
537        }
538
539        Ok(basin_id)
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn test_landscape_analyzer() {
549        let mut model = IsingModel::new(4);
550        model
551            .set_coupling(0, 1, -1.0)
552            .expect("set_coupling(0,1) should succeed");
553        model
554            .set_coupling(1, 2, -1.0)
555            .expect("set_coupling(1,2) should succeed");
556        model
557            .set_coupling(2, 3, -1.0)
558            .expect("set_coupling(2,3) should succeed");
559
560        let analyzer = LandscapeAnalyzer::new().with_max_samples(100).with_seed(42);
561
562        let points = analyzer
563            .sample_landscape(&model)
564            .expect("sample_landscape should succeed");
565        assert!(!points.is_empty());
566        assert!(points.len() <= 100);
567    }
568
569    #[test]
570    fn test_landscape_stats() {
571        let points = vec![
572            LandscapePoint {
573                configuration: vec![1, 1, 1],
574                energy: -2.0,
575                hamming_distance: None,
576                basin_id: None,
577            },
578            LandscapePoint {
579                configuration: vec![-1, -1, -1],
580                energy: -2.0,
581                hamming_distance: None,
582                basin_id: None,
583            },
584            LandscapePoint {
585                configuration: vec![1, -1, 1],
586                energy: 1.0,
587                hamming_distance: None,
588                basin_id: None,
589            },
590        ];
591
592        let stats = calculate_landscape_stats(&points);
593        assert_eq!(stats.num_configurations, 3);
594        assert_eq!(stats.min_energy, -2.0);
595        assert_eq!(stats.max_energy, 1.0);
596        assert_eq!(stats.ground_state_degeneracy, 2);
597        assert_eq!(stats.energy_gap, Some(3.0));
598    }
599
600    #[test]
601    fn test_hamming_distance() {
602        let config1 = vec![1, -1, 1, -1];
603        let config2 = vec![1, 1, -1, -1];
604        assert_eq!(hamming_distance(&config1, &config2), 2);
605
606        let config3 = vec![1, -1, 1, -1];
607        assert_eq!(hamming_distance(&config1, &config3), 0);
608    }
609
610    #[test]
611    fn test_basin_analyzer() {
612        let mut points = vec![
613            LandscapePoint {
614                configuration: vec![1, 1],
615                energy: -1.0,
616                hamming_distance: None,
617                basin_id: None,
618            },
619            LandscapePoint {
620                configuration: vec![1, -1],
621                energy: -0.9,
622                hamming_distance: None,
623                basin_id: None,
624            },
625            LandscapePoint {
626                configuration: vec![-1, -1],
627                energy: 2.0,
628                hamming_distance: None,
629                basin_id: None,
630            },
631        ];
632
633        let analyzer = BasinAnalyzer::new(0.2, 1);
634        let num_basins = analyzer
635            .identify_basins(&mut points)
636            .expect("identify_basins should succeed");
637
638        assert_eq!(num_basins, 2);
639        assert_eq!(points[0].basin_id, points[1].basin_id);
640        assert_ne!(points[0].basin_id, points[2].basin_id);
641    }
642}