Skip to main content

spin_sim/simulation/
xy.rs

1//! XY simulation using the shared realization, scheduling, clusters and tempering.
2use super::{driver, realization::XyRealization};
3use crate::{
4    clusters::fk::embedded_update,
5    config::{
6        AutocorrelationBackend, ClusterAction, ClusterConfig, ClusterMode, PtSchedule, SimConfig,
7        SweepMode,
8    },
9    geometry::Lattice,
10    spins::xy::{interaction, local_sweep, LocalMove, XyEmbedding},
11    statistics::{
12        autocorrelation::PrecisionAutocorr,
13        physics::{MomentBlock, PhysicsCollector, PhysicsOptions, PhysicsResult, PhysicsValues},
14    },
15};
16use rayon::prelude::*;
17use std::sync::atomic::AtomicBool;
18
19#[derive(Debug)]
20pub struct XyConfig {
21    pub simulation: SimConfig,
22    /// Fixed number of cluster updates at each scheduled cluster event.
23    pub cluster_updates: usize,
24    pub overrelaxation_sweeps: usize,
25    pub physics: PhysicsOptions,
26    /// Measure every this many sweeps after warmup. Near a transition consecutive
27    /// sweeps are strongly correlated, so a stride saves measurement work at little
28    /// statistical cost; block sizes and autocorrelation times are then in units of
29    /// measurements.
30    pub measure_interval: usize,
31}
32impl Default for XyConfig {
33    fn default() -> Self {
34        Self {
35            simulation: SimConfig {
36                n_sweeps: 1000,
37                warmup_sweeps: 250,
38                sweep_mode: SweepMode::Metropolis,
39                cluster_update: Some(ClusterConfig {
40                    interval: 1,
41                    mode: ClusterMode::Sw,
42                    action: ClusterAction::Update,
43                    collect_stats: false,
44                }),
45                pt_interval: None,
46                pt_schedule: PtSchedule::SingleRandomEdge,
47                overlap_cluster: None,
48                autocorrelation_max_lag: None,
49                autocorrelation_backend: AutocorrelationBackend::Ring,
50                sequential: false,
51                equilibration_diagnostic: false,
52                conditional_link_overlap: true,
53            },
54            cluster_updates: 1,
55            overrelaxation_sweeps: 0,
56            physics: PhysicsOptions::default(),
57            measure_interval: 1,
58        }
59    }
60}
61impl XyConfig {
62    fn validate(&self) -> Result<(), String> {
63        let cfg = &self.simulation;
64        cfg.validate()?;
65        if cfg.overlap_cluster.is_some() || cfg.equilibration_diagnostic {
66            return Err(
67                "overlap updates and Ising equilibration diagnostics are not supported for XY"
68                    .into(),
69            );
70        }
71        if self.cluster_updates == 0 {
72            return Err("cluster_updates must be positive".into());
73        }
74        if self.measure_interval == 0 {
75            return Err("measure_interval must be positive".into());
76        }
77        if cfg
78            .cluster_update
79            .as_ref()
80            .is_some_and(|c| c.action != ClusterAction::Update || c.collect_stats)
81        {
82            return Err("XY clusters support updates and visited-spin counts; Ising graph collectors are unsupported".into());
83        }
84        if cfg.sweep_mode == SweepMode::None && cfg.cluster_update.is_none() {
85            return Err("XY sampling requires Metropolis, Gibbs or cluster updates".into());
86        }
87        Ok(())
88    }
89}
90
91pub struct XyDisorderResult {
92    pub physics: PhysicsResult,
93    /// τ_int per temperature in measured sweeps; NaN for a series without variance.
94    pub energy_tau: Vec<f64>,
95    pub mags2_tau: Vec<f64>,
96    /// Whether each τ's Sokal window closed; if not, τ is a truncated lower bound.
97    pub energy_tau_converged: Vec<bool>,
98    pub mags2_tau_converged: Vec<bool>,
99    /// Counts per temperature, summed over replicas, including warmup.
100    pub cluster_updates: Vec<u64>,
101    /// SW visits N spins; Wolff visits its seed cluster, including vacant spins.
102    pub visited_spins: Vec<u64>,
103    /// Accepted Metropolis proposals per temperature over the measured sweeps, summed
104    /// over replicas, and the proposals made: one per occupied site and sweep. The
105    /// ratio shows where uniform proposals freeze and the heat bath pays off.
106    pub metropolis_accepted: Vec<u64>,
107    pub metropolis_attempts: Vec<u64>,
108}
109pub struct XyResult {
110    pub values: PhysicsValues,
111    pub per_disorder: Vec<XyDisorderResult>,
112}
113
114/// A batch of XY disorder realizations on one periodic hypercubic lattice.
115///
116/// ```
117/// use spin_sim::{XyConfig, XySimulation};
118/// use std::sync::atomic::AtomicBool;
119/// let mut xy = XySimulation::new(
120///     vec![4, 4], vec![vec![1.0; 32]], &[0.8, 1.2], 1, None, 42,
121/// ).unwrap();
122/// let result = xy.sample(&XyConfig::default(), &AtomicBool::new(false), &|| {}).unwrap();
123/// assert_eq!(result.values["energies"].len(), 2);
124/// ```
125pub struct XySimulation {
126    pub lattice: Lattice,
127    pub realizations: Vec<XyRealization>,
128    pub occupation: Vec<Vec<bool>>,
129    pub n_replicas: usize,
130    pub n_temps: usize,
131    constructor_seed: u64,
132}
133impl XySimulation {
134    pub fn new(
135        shape: Vec<usize>,
136        couplings: Vec<Vec<f64>>,
137        temperatures: &[f64],
138        n_replicas: usize,
139        occupation: Option<Vec<Vec<bool>>>,
140        seed: u64,
141    ) -> Result<Self, String> {
142        if shape.is_empty() || shape.iter().any(|&l| l < 3) {
143            return Err("XY hypercubic extents must be at least three".into());
144        }
145        let n = shape
146            .iter()
147            .try_fold(1usize, |n, &l| n.checked_mul(l))
148            .ok_or("lattice size overflow")?;
149        if n > u32::MAX as usize || n.checked_mul(shape.len()).is_none() {
150            return Err("lattice is too large".into());
151        }
152        if n_replicas == 0
153            || temperatures.is_empty()
154            || temperatures.iter().any(|t| !t.is_finite() || *t <= 0.0)
155        {
156            return Err(
157                "temperatures must be nonempty, positive and finite; n_replicas must be positive"
158                    .into(),
159            );
160        }
161        n_replicas
162            .checked_mul(temperatures.len())
163            .and_then(|systems| systems.checked_mul(n))
164            .ok_or("spin count overflow")?;
165        if couplings.is_empty()
166            || couplings
167                .iter()
168                .any(|j| j.len() != n * shape.len() || j.iter().any(|x| !x.is_finite()))
169        {
170            return Err("couplings must have the lattice shape and be finite".into());
171        }
172        let occupation = occupation.unwrap_or_else(|| vec![vec![true; n]; couplings.len()]);
173        if occupation.len() != couplings.len() || occupation.iter().any(|m| m.len() != n) {
174            return Err("occupation must match the disorder and lattice dimensions".into());
175        }
176        let lattice = Lattice::new(shape);
177        let realizations = couplings
178            .into_iter()
179            .enumerate()
180            .map(|(d, mut j)| {
181                for i in 0..n {
182                    for axis in 0..lattice.n_dims {
183                        if !occupation[d][i] || !occupation[d][lattice.neighbor_fwd(i, axis)] {
184                            j[i * lattice.n_dims + axis] = 0.0;
185                        }
186                    }
187                }
188                XyRealization::new(
189                    &lattice,
190                    j,
191                    temperatures,
192                    n_replicas,
193                    super::realization::realization_seed(seed, d),
194                )
195            })
196            .collect();
197        Ok(Self {
198            lattice,
199            realizations,
200            occupation,
201            n_replicas,
202            n_temps: temperatures.len(),
203            constructor_seed: seed,
204        })
205    }
206    pub fn reset(&mut self, seed: Option<u64>) {
207        for (d, real) in self.realizations.iter_mut().enumerate() {
208            real.reset(
209                &self.lattice,
210                self.n_replicas,
211                self.n_temps,
212                super::realization::realization_seed(seed.unwrap_or(self.constructor_seed), d),
213            );
214        }
215    }
216    pub fn sample(
217        &mut self,
218        config: &XyConfig,
219        interrupted: &AtomicBool,
220        on_sweep: &(dyn Fn() + Sync),
221    ) -> Result<XyResult, String> {
222        config.validate()?;
223        driver::validate_batch(
224            &self.lattice,
225            &self.realizations,
226            self.n_replicas,
227            self.n_temps,
228            &config.simulation,
229        )?;
230        // Preflight the physics options and masks before any realization mutates.
231        config.physics.validate(&self.lattice, 2)?;
232        if self.occupation.len() != self.realizations.len()
233            || self
234                .occupation
235                .iter()
236                .any(|m| m.len() != self.lattice.n_spins)
237        {
238            return Err("occupation dimensions changed".into());
239        }
240        for (real, mask) in self.realizations.iter().zip(&self.occupation) {
241            // |r² - 1| <= 2e-8 bounds |r - 1| by 1e-8; NaN components fail as well.
242            if real.spins.iter().any(|s| {
243                let excess = (s[0] * s[0] + s[1] * s[1] - 1.0).abs();
244                excess.is_nan() || excess > 2e-8
245            }) {
246                return Err("XY spins must be finite unit vectors".into());
247            }
248            for i in 0..self.lattice.n_spins {
249                for d in 0..self.lattice.n_dims {
250                    if (!mask[i] || !mask[self.lattice.neighbor_fwd(i, d)])
251                        && real.couplings[i * self.lattice.n_dims + d] != 0.0
252                    {
253                        return Err("vacant sites must have zero incident couplings".into());
254                    }
255                }
256            }
257        }
258        let sequential = driver::effective_sequential(&config.simulation, self.realizations.len());
259        // Disorder batches already measure in parallel; split temperatures only for idle threads.
260        let measurement_groups = if sequential {
261            1
262        } else {
263            (rayon::current_num_threads() / self.realizations.len()).max(1)
264        };
265        let per_disorder = driver::map_realizations(&mut self.realizations, |d, real| {
266            run_xy(
267                &self.lattice,
268                real,
269                &self.occupation[d],
270                self.n_replicas,
271                self.n_temps,
272                config,
273                sequential,
274                measurement_groups,
275                interrupted,
276                on_sweep,
277            )
278        })?;
279        let values = PhysicsResult::aggregate(
280            per_disorder.iter().map(|r| &r.physics),
281            &self.lattice.shape,
282            &self.realizations[0].temperatures[..self.n_temps],
283            2,
284        );
285        Ok(XyResult {
286            values,
287            per_disorder,
288        })
289    }
290}
291
292/// Physics collectors, each owning a fixed residue class of temperatures.
293///
294/// A temperature's moments are accumulated by one collector in replica order,
295/// exactly as with a single collector, so results do not depend on the grouping.
296struct Measurement {
297    groups: Vec<PhysicsCollector>,
298}
299impl Measurement {
300    fn new(
301        lattice: &Lattice,
302        n_temps: usize,
303        options: &PhysicsOptions,
304        max_groups: usize,
305    ) -> Result<Self, String> {
306        let n_groups = n_temps.min(max_groups).max(1);
307        let groups = (0..n_groups)
308            .map(|group| {
309                let mut collector = PhysicsCollector::new(lattice, n_temps, options, 2)?;
310                if n_groups > 1 {
311                    collector.own_block_temperatures(|t| t % n_groups == group);
312                }
313                Ok(collector)
314            })
315            .collect::<Result<_, String>>()?;
316        Ok(Self { groups })
317    }
318
319    /// Measure every system; returns per-slot physical energy/site and m².
320    fn measure(
321        &mut self,
322        lattice: &Lattice,
323        real: &XyRealization,
324        occupation: &[bool],
325        n_temps: usize,
326        out: &mut [[f64; 2]],
327    ) {
328        let n_groups = self.groups.len();
329        let n = lattice.n_spins;
330        let measure_group = |(group, collector): (usize, &mut PhysicsCollector)| {
331            let mut measured = Vec::new();
332            for t in (group..n_temps).step_by(n_groups) {
333                for slot in (t..real.system_ids.len()).step_by(n_temps) {
334                    let id = real.system_ids[slot];
335                    let spins = &real.spins[id * n..(id + 1) * n];
336                    let values = collector.measure(lattice, spins, &real.couplings, occupation, t);
337                    measured.push((slot, values));
338                }
339            }
340            measured
341        };
342        let measured: Vec<_> = if n_groups == 1 {
343            vec![measure_group((0, &mut self.groups[0]))]
344        } else {
345            self.groups
346                .par_iter_mut()
347                .enumerate()
348                .map(measure_group)
349                .collect()
350        };
351        for (slot, values) in measured.into_iter().flatten() {
352            out[slot] = values;
353        }
354    }
355
356    fn end_sweep(&mut self, n_replicas: usize) {
357        for collector in &mut self.groups {
358            collector.end_sweep(n_replicas);
359        }
360    }
361
362    fn finish(self, n_temps: usize) -> PhysicsResult {
363        let mut groups: Vec<_> = self.groups.into_iter().map(|c| c.finish()).collect();
364        if groups.len() == 1 {
365            return groups.pop().unwrap();
366        }
367        let n_groups = groups.len();
368        let owner = |t: usize| &groups[t % n_groups];
369        let blocks = (0..groups[0].blocks.len())
370            .map(|b| MomentBlock {
371                sums: (0..n_temps)
372                    .map(|t| owner(t).blocks[b].sums[t].clone())
373                    .collect(),
374                count: groups[0].blocks[b].count,
375                sweeps: groups[0].blocks[b].sweeps,
376            })
377            .collect();
378        PhysicsResult {
379            fields: groups[0].fields.clone(),
380            moments: (0..n_temps).map(|t| owner(t).moments[t].clone()).collect(),
381            count: groups[0].count,
382            blocks,
383        }
384    }
385}
386
387#[allow(clippy::too_many_arguments)]
388fn run_xy(
389    lattice: &Lattice,
390    real: &mut XyRealization,
391    occupation: &[bool],
392    n_replicas: usize,
393    n_temps: usize,
394    config: &XyConfig,
395    sequential: bool,
396    measurement_groups: usize,
397    interrupted: &AtomicBool,
398    on_sweep: &(dyn Fn() + Sync),
399) -> Result<XyDisorderResult, String> {
400    let cfg = &config.simulation;
401    let mut physics = Measurement::new(lattice, n_temps, &config.physics, measurement_groups)?;
402    let n_measures = cfg.n_measurement_sweeps().div_ceil(config.measure_interval);
403    let lag = cfg
404        .autocorrelation_max_lag
405        .map(|lag| lag.min(n_measures / 4).max(1));
406    let mut energy_ac = lag.map(|k| {
407        PrecisionAutocorr::<f64>::with_backend(k, n_temps, cfg.autocorrelation_backend, n_measures)
408    });
409    let mut m2_ac = lag.map(|k| {
410        PrecisionAutocorr::<f64>::with_backend(k, n_temps, cfg.autocorrelation_backend, n_measures)
411    });
412    let mut energy_buf = vec![0.0; n_temps];
413    let mut m2_buf = vec![0.0; n_temps];
414    let mut measured = vec![[0.0; 2]; real.system_ids.len()];
415    let mut visits = vec![0; n_temps * n_replicas];
416    let mut updates = vec![0; n_temps];
417    let mut metropolis_accepted = vec![0u64; n_temps * n_replicas];
418    let mut metropolis_passes = 0u64;
419    let occupied = (!occupation.iter().all(|&o| o)).then_some(occupation);
420    let local_move = match cfg.sweep_mode {
421        SweepMode::Metropolis => Some(LocalMove::Metropolis),
422        SweepMode::Gibbs => Some(LocalMove::HeatBath),
423        SweepMode::None => None,
424    };
425    driver::run_sweeps(cfg, interrupted, on_sweep, real.schedule.sweeps, |step| {
426        let moves = local_move.into_iter().chain(std::iter::repeat_n(
427            LocalMove::Overrelaxation,
428            config.overrelaxation_sweeps,
429        ));
430        for kind in moves {
431            let count = step.record && kind == LocalMove::Metropolis;
432            metropolis_passes += u64::from(count);
433            local_sweep(
434                lattice,
435                &mut real.spins,
436                &real.couplings,
437                &real.temperatures,
438                &real.system_ids,
439                &mut real.rngs,
440                occupied,
441                sequential,
442                kind,
443                count.then_some(metropolis_accepted.as_mut_slice()),
444            );
445        }
446        if step.cluster {
447            let cluster = cfg.cluster_update.as_ref().unwrap();
448            for _ in 0..config.cluster_updates {
449                embedded_update::<XyEmbedding>(
450                    lattice,
451                    &mut real.spins,
452                    &real.couplings,
453                    &real.temperatures,
454                    &real.system_ids,
455                    &mut real.rngs,
456                    cluster.mode == ClusterMode::Wolff,
457                    ClusterAction::Update,
458                    None,
459                    None,
460                    sequential,
461                    Some(&mut visits),
462                );
463                for count in &mut updates {
464                    *count += n_replicas as u64;
465                }
466            }
467        }
468        let measure =
469            step.record && (step.index - cfg.warmup_sweeps).is_multiple_of(config.measure_interval);
470        if measure {
471            physics.measure(lattice, real, occupation, n_temps, &mut measured);
472            energy_buf.fill(0.0);
473            m2_buf.fill(0.0);
474            for (slot, &id) in real.system_ids.iter().enumerate() {
475                let t = slot % n_temps;
476                real.energies[id] = -measured[slot][0];
477                energy_buf[t] += measured[slot][0] / n_replicas as f64;
478                m2_buf[t] += measured[slot][1] / n_replicas as f64;
479            }
480            physics.end_sweep(n_replicas);
481            if let Some(ac) = &mut energy_ac {
482                ac.push(&energy_buf);
483            }
484            if let Some(ac) = &mut m2_ac {
485                ac.push(&m2_buf);
486            }
487        } else if step.temper {
488            let n = lattice.n_spins;
489            let refresh = |(energy, spins): (&mut f64, &[[f64; 2]])| {
490                *energy = interaction(lattice, spins, &real.couplings) / n as f64;
491            };
492            if sequential {
493                real.energies
494                    .iter_mut()
495                    .zip(real.spins.chunks_exact(n))
496                    .for_each(refresh);
497            } else {
498                real.energies
499                    .par_iter_mut()
500                    .zip(real.spins.par_chunks_exact(n))
501                    .for_each(refresh);
502            }
503        }
504        if step.temper {
505            real.temper(lattice.n_spins, n_replicas, n_temps, cfg.pt_schedule);
506        }
507        real.schedule.sweeps += 1;
508    })?;
509    let (energy_tau, energy_tau_converged) = driver::tau_estimates(energy_ac.as_ref());
510    let (mags2_tau, mags2_tau_converged) = driver::tau_estimates(m2_ac.as_ref());
511    let per_temperature = |slots: Vec<u64>| {
512        let mut totals = vec![0; n_temps];
513        for (slot, count) in slots.into_iter().enumerate() {
514            totals[slot % n_temps] += count;
515        }
516        totals
517    };
518    let n_occupied = occupation.iter().filter(|&&o| o).count() as u64;
519    Ok(XyDisorderResult {
520        physics: physics.finish(n_temps),
521        energy_tau,
522        mags2_tau,
523        energy_tau_converged,
524        mags2_tau_converged,
525        cluster_updates: updates,
526        visited_spins: per_temperature(visits),
527        metropolis_accepted: per_temperature(metropolis_accepted),
528        metropolis_attempts: vec![metropolis_passes * n_replicas as u64 * n_occupied; n_temps],
529    })
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    fn sample_with_threads(sequential: bool, threads: usize) -> (XyResult, Vec<u64>) {
537        let couplings = (0..40).map(|i| ((i * 7) % 11) as f64 / 5.0 - 0.6).collect();
538        let occupation = (0..20).map(|i| i != 6).collect();
539        let temperatures = [0.5, 0.8, 1.1, 1.6, 2.4];
540        let mut simulation = XySimulation::new(
541            vec![5, 4],
542            vec![couplings],
543            &temperatures,
544            2,
545            Some(vec![occupation]),
546            21,
547        )
548        .unwrap();
549        let mut config = XyConfig::default();
550        let cfg = &mut config.simulation;
551        (cfg.n_sweeps, cfg.warmup_sweeps, cfg.sequential) = (64, 21, sequential);
552        (cfg.pt_interval, cfg.autocorrelation_max_lag) = (Some(1), Some(4));
553        config.overrelaxation_sweeps = 1;
554        config.physics = PhysicsOptions {
555            displacements: vec![vec![1, 1]],
556            vortices: true,
557            block_size: Some(7),
558        };
559        let pool = rayon::ThreadPoolBuilder::new()
560            .num_threads(threads)
561            .build()
562            .unwrap();
563        let result = pool
564            .install(|| simulation.sample(&config, &AtomicBool::new(false), &|| {}))
565            .unwrap();
566        let spins = simulation.realizations[0]
567            .spins
568            .iter()
569            .flatten()
570            .map(|v| v.to_bits())
571            .collect();
572        (result, spins)
573    }
574
575    /// A stride measures every k-th sweep after warmup; Metropolis counts cover the
576    /// measured passes over every occupied site.
577    #[test]
578    fn measurement_stride_and_metropolis_counts() {
579        let mut xy =
580            XySimulation::new(vec![4, 4], vec![vec![1.0; 32]], &[0.4, 2.0], 2, None, 7).unwrap();
581        let mut config = XyConfig::default();
582        config.simulation.n_sweeps = 25;
583        config.simulation.warmup_sweeps = 5;
584        config.simulation.cluster_update = None;
585        config.measure_interval = 3;
586        let result = xy.sample(&config, &AtomicBool::new(false), &|| {}).unwrap();
587        let disorder = &result.per_disorder[0];
588        // Measured sweeps 5, 8, ..., 23: seven, each over two replicas.
589        assert_eq!(disorder.physics.count, 14);
590        assert_eq!(disorder.metropolis_attempts, vec![20 * 2 * 16; 2]);
591        let rate: Vec<f64> = disorder
592            .metropolis_accepted
593            .iter()
594            .zip(&disorder.metropolis_attempts)
595            .map(|(&accepted, &attempts)| accepted as f64 / attempts as f64)
596            .collect();
597        assert!(
598            rate.iter().all(|&rate| rate > 0.0 && rate <= 1.0),
599            "{rate:?}"
600        );
601        assert!(
602            rate[0] < rate[1],
603            "colder temperatures accept fewer proposals: {rate:?}"
604        );
605
606        config.measure_interval = 0;
607        assert!(xy.sample(&config, &AtomicBool::new(false), &|| {}).is_err());
608    }
609
610    #[test]
611    fn parallel_measurement_matches_sequential_for_any_thread_count() {
612        let bits = |rows: &[Vec<f64>]| -> Vec<Vec<u64>> {
613            rows.iter()
614                .map(|row| row.iter().map(|v| v.to_bits()).collect())
615                .collect()
616        };
617        let (reference, reference_spins) = sample_with_threads(true, 1);
618        let expected = &reference.per_disorder[0];
619        // Two, three and five temperature groups.
620        for threads in [2, 3, 8] {
621            let (result, spins) = sample_with_threads(false, threads);
622            assert_eq!(spins, reference_spins);
623            for (name, rows) in &reference.values {
624                assert_eq!(bits(rows), bits(&result.values[name]), "{name}");
625            }
626            let actual = &result.per_disorder[0];
627            assert_eq!(
628                bits(&actual.physics.moments),
629                bits(&expected.physics.moments)
630            );
631            assert_eq!(actual.physics.blocks.len(), expected.physics.blocks.len());
632            for (a, b) in actual.physics.blocks.iter().zip(&expected.physics.blocks) {
633                assert_eq!((a.count, a.sweeps), (b.count, b.sweeps));
634                assert_eq!(bits(&a.sums), bits(&b.sums));
635            }
636            assert_eq!(
637                bits(&[actual.energy_tau.clone(), actual.mags2_tau.clone()]),
638                bits(&[expected.energy_tau.clone(), expected.mags2_tau.clone()])
639            );
640            assert_eq!(actual.visited_spins, expected.visited_spins);
641            assert_eq!(actual.metropolis_accepted, expected.metropolis_accepted);
642        }
643    }
644}