molpack/packer.rs
1//! Main packing orchestration.
2//! Port of the outer loop in `app/packmol.f90`.
3
4use std::sync::Arc;
5
6use molrs::Element;
7use molrs::spatial::region::simbox::SimBox;
8use molrs::types::F;
9use ndarray::Array1;
10use rand::SeedableRng;
11use rand::rngs::SmallRng;
12
13use crate::constraints::EvalMode;
14use crate::context::PackContext;
15use crate::error::PackError;
16use crate::euler::{compcart, eulerfixed};
17use crate::gencan::{GencanParams, GencanWorkspace, pgencan};
18use crate::handler::{
19 Handler, LammpsLogHandler, MolpackLogLevel, PhaseInfo, PhaseReport, StepInfo,
20};
21use crate::initial::{SwapState, init_xcart_from_x, initial};
22use crate::movebad::{MoveBadConfig, movebad};
23use crate::numerics::objective_small_floor;
24use crate::relaxer::RelaxerRunner;
25use crate::restraint::AtomRestraint;
26use crate::target::{CenteringMode, Target};
27
28/// Result of a packing run.
29///
30/// The `frame` contains an "atoms" block with x, y, z, element, mol_id
31/// columns — moved from the packing context (zero-copy ownership transfer).
32#[derive(Debug, Clone)]
33pub struct PackResult {
34 /// Atoms frame with x, y, z (f32), element (String), mol_id (i64).
35 pub frame: molrs::Frame,
36 /// Maximum inter-molecular distance violation at termination.
37 pub fdist: F,
38 /// Maximum constraint violation at termination.
39 pub frest: F,
40 /// Whether the packing converged (`fdist < precision && frest < precision`).
41 pub converged: bool,
42}
43
44impl PackResult {
45 /// Extract atom positions as `Vec<[F; 3]>` (SoA→AoS conversion).
46 pub fn positions(&self) -> Vec<[F; 3]> {
47 let atoms = self.frame.get("atoms").expect("frame has no 'atoms' block");
48 let x = atoms.get_float("x").expect("no 'x' column");
49 let y = atoms.get_float("y").expect("no 'y' column");
50 let z = atoms.get_float("z").expect("no 'z' column");
51 x.iter()
52 .zip(y.iter())
53 .zip(z.iter())
54 .map(|((&xi, &yi), &zi)| [xi, yi, zi])
55 .collect()
56 }
57
58 /// Number of atoms in the result.
59 #[inline]
60 pub fn natoms(&self) -> usize {
61 self.frame.get("atoms").and_then(|b| b.nrows()).unwrap_or(0)
62 }
63}
64
65/// Default Packmol parameters
66const PRECISION: F = 0.01;
67// Packmol default from getinp.f90: discale = 1.1d0
68const DISCALE: F = 1.1;
69/// Fixed GENCAN inner iteration limit (Packmol default maxit = 20).
70const GENCAN_MAXIT: usize = 20;
71/// Packmol default sidemax (getinp.f90).
72const SIDEMAX: F = 1000.0;
73/// Packmol default movefrac.
74const MOVEFRAC: F = 0.05;
75/// Default minimum atom-atom distance tolerance (Packmol's `dism` default = 2.0 Å).
76/// Atom radii are set to `tolerance / 2` for all atoms, matching Packmol's
77/// `radius(i) = dism/2.d0` (packmol.f90 line 283).
78const DEFAULT_TOLERANCE: F = 2.0;
79/// Default RNG seed (Packmol's `seed` default = 1234567, getinp.f90 line 33).
80const DEFAULT_SEED: u64 = 1_234_567;
81
82/// The packer.
83pub struct Molpack {
84 handlers: Vec<Box<dyn Handler>>,
85 /// Global restraints — broadcast to every target at `pack()` time
86 /// (semantic equivalence to calling `target.with_restraint(r.clone())`
87 /// on every target; no separate global-storage code path).
88 global_restraints: Vec<Arc<dyn AtomRestraint>>,
89 precision: F,
90 discale: F,
91 /// Minimum atom-atom distance (Packmol's `tolerance`/`dism`). Default 2.0 Å.
92 /// Atom radii = `tolerance / 2`.
93 tolerance: F,
94 /// GENCAN inner iterations (`maxit` keyword).
95 inner_iterations: usize,
96 /// Initialization outer loops (`nloop0` keyword). `None` means Packmol default (20*ntype).
97 init_passes: Option<usize>,
98 /// Maximum system half-size used in initial restmol stage (`sidemax` keyword).
99 init_box_half_size: F,
100 /// Fraction of molecules perturbed when packing stalls (Packmol's `movefrac`).
101 perturb_fraction: F,
102 /// Randomize perturbation target selection (Packmol's `movebadrandom`).
103 random_perturb: bool,
104 /// Master switch for the stall-perturbation heuristic (inverts Packmol's
105 /// `disable_movebad`: `true` = perturb enabled, `false` = disabled).
106 perturb: bool,
107 /// Seed for the internal RNG. Default `1_234_567` (Packmol's default;
108 /// deterministic — the same seed reproduces the same packing).
109 seed: u64,
110 /// Run the pair-kernel reductions on rayon. Off by default: see
111 /// [`with_parallel_eval`][Self::with_parallel_eval].
112 parallel_eval: bool,
113 /// Global periodic-boundary box, as set by
114 /// [`with_periodic_box`][Self::with_periodic_box] (Packmol `pbc`
115 /// keyword). When both this and a restraint-declared PBC are
116 /// present, they must match exactly or `pack()` returns
117 /// [`PackError::ConflictingPeriodicBoxes`].
118 periodic_box: Option<PeriodicSpec>,
119 /// Built-in LAMMPS-style screen output detail.
120 log_level: MolpackLogLevel,
121 /// Print every N outer iterations when `log_level` includes progress.
122 log_frequency: usize,
123 /// Reject initial random placements that overlap a fixed molecule
124 /// (Packmol's `avoid_overlap`, default on). Critical when packing a dense
125 /// solvent around a large fixed solute: without it ~15-20% of the solvent
126 /// seeds inside the solute, inflating the initial overlap ~2× and stalling
127 /// GENCAN. Has no effect when there are no fixed molecules.
128 avoid_overlap: bool,
129}
130
131impl Default for Molpack {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl Molpack {
138 /// Create a packer with default settings and no handlers.
139 ///
140 /// All tuning knobs are set via `with_*` methods below; they all have
141 /// defensible defaults, so `Molpack::new().pack(&targets, max_loops)`
142 /// is a valid invocation. The one argument that has no default is
143 /// `max_loops` — it depends on system size and convergence difficulty,
144 /// so it lives on the terminal [`pack`][Self::pack] call, not on the
145 /// builder.
146 pub fn new() -> Self {
147 Self {
148 handlers: Vec::new(),
149 global_restraints: Vec::new(),
150 precision: PRECISION,
151 discale: DISCALE,
152 tolerance: DEFAULT_TOLERANCE,
153 inner_iterations: GENCAN_MAXIT,
154 init_passes: None,
155 init_box_half_size: SIDEMAX,
156 perturb_fraction: MOVEFRAC,
157 random_perturb: false,
158 perturb: true,
159 seed: DEFAULT_SEED,
160 parallel_eval: false,
161 periodic_box: None,
162 log_level: MolpackLogLevel::Quiet,
163 log_frequency: 1,
164 avoid_overlap: true,
165 }
166 }
167
168 /// Append a progress handler. Multiple handlers compose in call order.
169 pub fn with_handler(mut self, h: impl Handler + 'static) -> Self {
170 self.handlers.push(Box::new(h));
171 self
172 }
173
174 /// Append a **global** restraint — applied to every atom of every
175 /// target at `pack()` time.
176 ///
177 /// Semantic equivalence (scope law):
178 /// ```text
179 /// molpack.with_global_restraint(r)
180 /// ≡ for each target: target.with_restraint(r.clone())
181 /// ```
182 ///
183 /// Implementation mirrors the equivalence — no separate "global
184 /// restraint" storage path in `PackContext`; the restraint is cloned
185 /// into each target's `molecule_restraints` when `pack()` is invoked.
186 pub fn with_global_restraint(mut self, r: impl AtomRestraint + 'static) -> Self {
187 self.global_restraints.push(Arc::new(r));
188 self
189 }
190
191 /// Convergence precision for `fdist` and `frest` (default `0.01`).
192 pub fn with_precision(mut self, p: F) -> Self {
193 self.precision = p;
194 self
195 }
196
197 /// Minimum atom-atom distance tolerance (default `2.0 Å`).
198 /// Atom radii are set to `tolerance / 2`.
199 pub fn with_tolerance(mut self, t: F) -> Self {
200 self.tolerance = t;
201 self
202 }
203
204 /// GENCAN inner iteration count (default `20`; Packmol `maxit`).
205 pub fn with_inner_iterations(mut self, n: usize) -> Self {
206 self.inner_iterations = n;
207 self
208 }
209
210 /// Whether to reject initial random placements that overlap a fixed
211 /// molecule (default `true`; Packmol's `avoid_overlap`). Leave on unless
212 /// you have a specific reason to allow solvent to seed inside a fixed
213 /// solute — disabling it can slow dense-solvation packing by 10× or more.
214 pub fn with_avoid_overlap(mut self, enabled: bool) -> Self {
215 self.avoid_overlap = enabled;
216 self
217 }
218
219 /// Initialization outer-loop passes (Packmol `nloop0`).
220 /// `0` restores the Packmol default of `20 * ntype`.
221 pub fn with_init_passes(mut self, n: usize) -> Self {
222 self.init_passes = if n == 0 { None } else { Some(n) };
223 self
224 }
225
226 /// Maximum half-size of the initial placement box (default `1000.0`;
227 /// Packmol `sidemax`).
228 pub fn with_init_box_half_size(mut self, f: F) -> Self {
229 self.init_box_half_size = f;
230 self
231 }
232
233 /// Declare a global periodic-boundary box (Packmol `pbc`). Every axis
234 /// is treated as periodic. When set, the packer's cell grid is built
235 /// from `max - min`, bypassing the fallback that derives a box from
236 /// post-Phase-1 atom positions — which can be ±`sidemax` wide when
237 /// the script has no spatial constraints and drives `ncells` to
238 /// 10⁸+ cells.
239 ///
240 /// If any restraint also declares a `periodic_box()`, the two must
241 /// match exactly (bounds + flags) or `pack()` returns
242 /// [`PackError::ConflictingPeriodicBoxes`].
243 pub fn with_periodic_box(mut self, min: [F; 3], max: [F; 3]) -> Self {
244 self.periodic_box = Some((min, max, [true; 3]));
245 self
246 }
247
248 /// Fraction of molecules re-sampled when packing stalls (default `0.05`;
249 /// Packmol `movefrac`).
250 pub fn with_perturb_fraction(mut self, f: F) -> Self {
251 self.perturb_fraction = f;
252 self
253 }
254
255 /// Randomize perturbation target selection (default `false`;
256 /// Packmol `movebadrandom`).
257 pub fn with_random_perturb(mut self, enabled: bool) -> Self {
258 self.random_perturb = enabled;
259 self
260 }
261
262 /// Enable the stall-perturbation heuristic (default `true`;
263 /// inverts Packmol's `disable_movebad`). Pass `false` to disable.
264 pub fn with_perturb(mut self, enabled: bool) -> Self {
265 self.perturb = enabled;
266 self
267 }
268
269 /// Seed for the internal RNG (default `1_234_567`, matching Packmol;
270 /// deterministic — the same seed reproduces the same packing).
271 pub fn with_seed(mut self, seed: u64) -> Self {
272 self.seed = seed;
273 self
274 }
275
276 /// Run the pair-kernel reductions on rayon worker threads (default
277 /// `false`).
278 ///
279 /// Parallelism is opt-in because the crossover where rayon pays off
280 /// is **workload-shaped, not size-shaped** — the per-call parallel
281 /// speedup measured on isolated `compute_fg` doesn't predict
282 /// end-to-end `Molpack::pack` wall clock (task-dispatch overhead
283 /// accumulates across thousands of calls per pack; the perturbation
284 /// pass skips the parallel path entirely; real workloads often
285 /// *regress* even when `active_cells.len()` clears any naive
286 /// threshold). The user knows their workload shape; the library
287 /// doesn't.
288 ///
289 /// Compile with `--features rayon` for this flag to have any
290 /// effect. Without the feature the pair kernel is serial
291 /// unconditionally and this setting is silently ignored.
292 pub fn with_parallel_eval(mut self, enabled: bool) -> Self {
293 self.parallel_eval = enabled;
294 self
295 }
296
297 /// Enable or disable built-in LAMMPS-style screen output.
298 ///
299 /// This is intentionally a builder-level concern: `pack()` returns the
300 /// packed [`molrs::Frame`], while timing, optimization diagnostics, and
301 /// system summaries are streamed to stderr when enabled.
302 pub fn with_lammps_output(mut self, enabled: bool) -> Self {
303 self.log_level = if enabled {
304 MolpackLogLevel::Progress
305 } else {
306 MolpackLogLevel::Quiet
307 };
308 self
309 }
310
311 /// Set built-in screen-log detail.
312 ///
313 /// `Quiet` is the default. `Summary` prints system/phase/final summaries,
314 /// `Progress` also prints thermo-style per-step lines, and `Verbose` adds
315 /// extra diagnostic columns.
316 pub fn with_log_level(mut self, level: MolpackLogLevel) -> Self {
317 self.log_level = level;
318 self
319 }
320
321 /// Print every `n` outer iterations for progress/verbose logs.
322 ///
323 /// Values below 1 are clamped to 1.
324 pub fn with_log_frequency(mut self, n: usize) -> Self {
325 self.log_frequency = n.max(1);
326 self
327 }
328
329 /// Run the packing.
330 ///
331 /// `max_loops` is the outer iteration budget; it is positional
332 /// because there is no defensible default (the right value depends
333 /// on system size and convergence difficulty). Every other knob
334 /// lives on the builder.
335 ///
336 /// Returns the packed [`molrs::Frame`]. Enable built-in screen logging
337 /// with [`with_lammps_output`][Self::with_lammps_output] or
338 /// [`with_log_level`][Self::with_log_level] for timing, optimization
339 /// diagnostics, and system summaries.
340 pub fn pack(
341 &mut self,
342 targets: &[Target],
343 max_loops: usize,
344 ) -> Result<molrs::Frame, PackError> {
345 Ok(self.pack_with_report(targets, max_loops)?.frame)
346 }
347
348 /// Run the packing and retain structured convergence diagnostics.
349 ///
350 /// This is mainly for tests, bindings, and advanced programmatic callers.
351 /// The primary user-facing API is [`pack`][Self::pack], which returns only
352 /// the packed frame.
353 pub fn pack_with_report(
354 &mut self,
355 targets: &[Target],
356 max_loops: usize,
357 ) -> Result<PackResult, PackError> {
358 if targets.is_empty() {
359 return Err(PackError::NoTargets);
360 }
361
362 for (i, t) in targets.iter().enumerate() {
363 if t.natoms() == 0 {
364 return Err(PackError::EmptyMolecule(i));
365 }
366 }
367
368 // Scope equivalence (spec §4): broadcast global restraints to each target.
369 // If none were added via `Molpack::add_restraint`, this is a no-op.
370 let broadcast_targets: Vec<Target>;
371 let targets: &[Target] = if self.global_restraints.is_empty() {
372 targets
373 } else {
374 broadcast_targets = targets
375 .iter()
376 .map(|t| {
377 let mut t = t.clone();
378 for r in &self.global_restraints {
379 t.molecule_restraints.push(Arc::clone(r));
380 }
381 t
382 })
383 .collect();
384 &broadcast_targets
385 };
386 // Derive the system periodic box from two independent sources:
387 // 1. `Molpack::with_periodic_box` — global PBC (script `pbc`).
388 // 2. `AtomRestraint::periodic_box` — per-restraint declarations,
389 // e.g. `InsideBoxRestraint` with any axis marked periodic.
390 //
391 // The two must not disagree; if both are present they must
392 // match exactly (same bounds, same per-axis flags). Validate
393 // the global PBC extent here since `derive_periodic_box`
394 // already does this for restraint-sourced boxes.
395 if let Some((min, max, _)) = self.periodic_box {
396 let length = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
397 if length.iter().any(|&v| v <= 0.0) {
398 return Err(PackError::InvalidPBCBox { min, max });
399 }
400 }
401 let pbc = match (self.periodic_box, derive_periodic_box(targets)?) {
402 (None, derived) => derived,
403 (Some(global), None) => Some(global),
404 (Some(global), Some(derived)) if global == derived => Some(global),
405 (Some(global), Some(derived)) => {
406 return Err(PackError::ConflictingPeriodicBoxes {
407 first: global,
408 second: derived,
409 });
410 }
411 };
412
413 let mut rng = SmallRng::seed_from_u64(self.seed);
414
415 // Split into free and fixed targets
416 let free_targets: Vec<&Target> = targets.iter().filter(|t| t.fixed_at.is_none()).collect();
417 let fixed_targets: Vec<&Target> = targets.iter().filter(|t| t.fixed_at.is_some()).collect();
418
419 let ntype = free_targets.len();
420 let ntype_with_fixed = ntype + fixed_targets.len();
421
422 let mut handlers = std::mem::take(&mut self.handlers);
423 if self.log_level.is_enabled() {
424 handlers.push(Box::new(LammpsLogHandler::new(
425 self.log_level,
426 self.log_frequency,
427 self.tolerance,
428 self.precision,
429 self.seed,
430 max_loops,
431 ntype_with_fixed,
432 pbc,
433 )));
434 }
435
436 // Count atoms
437 let ntotmol_free: usize = free_targets.iter().map(|t| t.count).sum();
438 let ntotat_free: usize = free_targets.iter().map(|t| t.count * t.natoms()).sum();
439 let ntotat_fixed: usize = fixed_targets.iter().map(|t| t.natoms()).sum();
440 let ntotat = ntotat_free + ntotat_fixed;
441
442 // Variable count: 3N COM + 3N Euler angles (only free molecules)
443 let n = 6 * ntotmol_free;
444
445 // Build PackContext
446 let mut sys = PackContext::new(ntotat, ntotmol_free, ntype);
447 sys.ntype_with_fixed = ntype_with_fixed;
448 sys.nfixedat = ntotat_fixed;
449 // comptype is initialized with size ntype; resize to include fixed types
450 sys.comptype = vec![true; ntype_with_fixed];
451
452 // Fill nmols, natoms, idfirst for free types
453 let mut cum_atoms = 0usize;
454 let mut coor = Vec::new();
455 let mut maxmove_per_type = vec![0usize; ntype];
456
457 sys.nmols = vec![0; ntype_with_fixed];
458 sys.natoms = vec![0; ntype_with_fixed];
459 sys.idfirst = vec![0; ntype_with_fixed];
460 sys.constrain_rot = vec![[false; 3]; ntype];
461 sys.rot_bound = vec![[[0.0; 2]; 3]; ntype];
462
463 for (itype, target) in free_targets.iter().enumerate() {
464 sys.nmols[itype] = target.count;
465 sys.natoms[itype] = target.natoms();
466 sys.idfirst[itype] = cum_atoms;
467 coor.extend_from_slice(reference_coords(target));
468 cum_atoms += target.natoms();
469
470 maxmove_per_type[itype] = target.perturb_budget.unwrap_or(target.count);
471 for k in 0..3 {
472 if let Some((center, half_width)) = target.rotation_bound[k] {
473 sys.constrain_rot[itype][k] = true;
474 sys.rot_bound[itype][k][0] = center.radians();
475 sys.rot_bound[itype][k][1] = half_width.radians();
476 }
477 }
478 }
479
480 for (fi, target) in fixed_targets.iter().enumerate() {
481 let itype = ntype + fi;
482 sys.nmols[itype] = 1;
483 sys.natoms[itype] = target.natoms();
484 sys.idfirst[itype] = cum_atoms;
485 coor.extend_from_slice(reference_coords(target));
486 cum_atoms += target.natoms();
487 }
488 sys.coor = coor;
489
490 // Assign radii, element symbols, and per-atom (itype, imol) tags.
491 // Packmol uses `radius = tolerance/2` for ALL atoms (packmol.f90 line 283:
492 // `radius(i) = dism/2.d0`), not VdW radii from the PDB file.
493 // `ibtype` / `ibmol` are derivable from position in the sequential
494 // atom layout, so we set them here once instead of having
495 // `insert_atom_in_cell` rewrite the same constants on every eval.
496 let atom_radius = self.tolerance / 2.0;
497 let mut icart = 0usize;
498 for (itype, target) in free_targets.iter().enumerate() {
499 for imol in 0..target.count {
500 for iatom in 0..target.natoms() {
501 sys.radius[icart] = atom_radius;
502 sys.radius_ini[icart] = atom_radius;
503 sys.ibtype[icart] = itype;
504 sys.ibmol[icart] = imol;
505 sys.elements[icart] = Element::by_symbol(&target.elements[iatom]);
506 icart += 1;
507 }
508 }
509 }
510 for (fi, target) in fixed_targets.iter().enumerate() {
511 let itype = ntype + fi;
512 for iatom in 0..target.natoms() {
513 sys.radius[icart] = atom_radius;
514 sys.radius_ini[icart] = atom_radius;
515 sys.ibtype[icart] = itype;
516 sys.ibmol[icart] = 0;
517 sys.elements[icart] = Element::by_symbol(&target.elements[iatom]);
518 icart += 1;
519 }
520 }
521
522 // Assign restraints: per-atom
523 let mut irest_pool = Vec::new();
524 let mut iratom_lists: Vec<Vec<usize>> = vec![Vec::new(); ntotat];
525 let mut icart = 0usize;
526 for target in free_targets.iter() {
527 for _imol in 0..target.count {
528 for iatom in 0..target.natoms() {
529 // molecule-level restraints applied to all atoms
530 for r in &target.molecule_restraints {
531 let irest = irest_pool.len();
532 irest_pool.push(std::sync::Arc::clone(r));
533 iratom_lists[icart].push(irest);
534 }
535 // atom-subset restraints
536 for (indices, restraint) in &target.atom_restraints {
537 if indices.contains(&iatom) {
538 let irest = irest_pool.len();
539 irest_pool.push(std::sync::Arc::clone(restraint));
540 iratom_lists[icart].push(irest);
541 }
542 }
543 icart += 1;
544 }
545 }
546 }
547 // Fixed atoms: no restraints needed (they are placed directly)
548 sys.restraints = irest_pool;
549 sys.iratom_offsets.clear();
550 sys.iratom_offsets.reserve(ntotat + 1);
551 sys.iratom_offsets.push(0);
552 for atom_restraints in &iratom_lists {
553 let next = sys.iratom_offsets.last().copied().unwrap_or(0) + atom_restraints.len();
554 sys.iratom_offsets.push(next);
555 }
556 sys.iratom_data.clear();
557 sys.iratom_data
558 .reserve(sys.iratom_offsets.last().copied().unwrap_or(0));
559 for atom_restraints in iratom_lists {
560 sys.iratom_data.extend(atom_restraints);
561 }
562
563 // Group-level (collective) restraints: one entry per (free type, restraint).
564 // The free target's position is its 0-based type index `itype`.
565 sys.collective.clear();
566 for (itype, target) in free_targets.iter().enumerate() {
567 for r in &target.collective_restraints {
568 sys.collective.push((itype, std::sync::Arc::clone(r)));
569 }
570 }
571
572 // Handle fixed molecules: place them using eulerfixed
573 let free_atoms = ntotat_free;
574 let mut fixed_icart = free_atoms;
575 for target in fixed_targets.iter() {
576 let fp = target.fixed_at.as_ref().unwrap();
577 let (v1, v2, v3) = eulerfixed(
578 fp.orientation[0].radians(),
579 fp.orientation[1].radians(),
580 fp.orientation[2].radians(),
581 );
582 let ref_coords = reference_coords(target);
583 for ref_coord in ref_coords.iter().take(target.natoms()) {
584 let pos = compcart(&fp.position, ref_coord, &v1, &v2, &v3);
585 sys.xcart[fixed_icart] = pos;
586 sys.fixedatom[fixed_icart] = true;
587 fixed_icart += 1;
588 }
589 }
590 // Populate the AoS `atom_props` mirror from the individual per-atom
591 // Vecs now that every hot-loop field is finalized. `sync_atom_props`
592 // also refreshes the `any_fixed_atoms` / `any_short_radius`
593 // summary flags used by the hot-loop fast paths.
594 sys.sync_atom_props();
595 // Plumb the builder's opt-in parallel flag through to the
596 // objective kernels.
597 sys.parallel_pair_eval = self.parallel_eval;
598
599 // Write constant columns (element, mol_id) into the output frame.
600 // These don't change during optimization; positions are added at the end.
601 crate::frame::init_frame_constants(&mut sys);
602
603 // Initialize x vector
604 let mut x = vec![0.0 as F; n];
605
606 // Notify handlers immediately (before any heavy computation)
607 for h in handlers.iter_mut() {
608 h.on_start(ntotat, ntotmol_free);
609 }
610
611 // Run initialization
612 sys.ntotmol = ntotmol_free;
613 let init_passes = self.init_passes.unwrap_or(20 * ntype);
614 let movebad_cfg = MoveBadConfig {
615 movefrac: self.perturb_fraction,
616 maxmove_per_type: &maxmove_per_type,
617 movebadrandom: self.random_perturb,
618 gencan_maxit: self.inner_iterations,
619 };
620 initial(
621 &mut x,
622 &mut sys,
623 self.precision,
624 self.discale,
625 self.init_box_half_size,
626 init_passes,
627 pbc,
628 self.avoid_overlap,
629 &movebad_cfg,
630 &mut rng,
631 );
632
633 // Notify handlers: initialization complete, xcart is valid
634 for h in handlers.iter_mut() {
635 h.on_initialized(&sys);
636 }
637
638 // Build relaxer runners from target relaxers (RelaxerRunner carries mutable MC state).
639 // Each entry: (type_index, Vec<Box<dyn RelaxerRunner>>).
640 let mut relaxer_runners: Vec<(usize, Vec<Box<dyn RelaxerRunner>>)> = free_targets
641 .iter()
642 .enumerate()
643 .filter(|(_, t)| !t.relaxers.is_empty())
644 .map(|(i, t)| {
645 let base = sys.idfirst[i];
646 let na = sys.natoms[i];
647 let ref_slice = &sys.coor[base..base + na];
648 // Pass the molecule template (full topology) so a force-field
649 // relaxer can compile its potential; `None` for coord-only targets.
650 let frame = t.template.as_ref();
651 let runners = t
652 .relaxers
653 .iter()
654 .map(|r| r.spawn(frame, ref_slice))
655 .collect();
656 (i, runners)
657 })
658 .collect();
659
660 // max_loops controls the outer loop count, matching Packmol's `nloop` parameter.
661 let gencan_params = GencanParams {
662 maxit: self.inner_iterations,
663 maxfc: self.inner_iterations * 10,
664 iprint: 0,
665 ..Default::default()
666 };
667
668 let mut converged = false;
669 let mut gencan_workspace = GencanWorkspace::new();
670
671 // ── Main optimization loop ─────────────────────────────────────────────
672 //
673 // Matches Packmol's `app/packmol.f90` main loop exactly:
674 // For each type (itype 1..ntype): swaptype(action=1) → pack → restore
675 // Then all types (itype = ntype+1): pack with full x
676 //
677 // Per-type phases use a compact x (n = nmols[itype]*6) via SwapState,
678 // reducing GENCAN problem size by up to 60x vs full n.
679
680 // Save initial full x before phasing (Packmol swaptype action=0 at line 740)
681 let mut swap = SwapState::init(&x, &sys);
682
683 let total_phases = ntype + 1;
684
685 for phase in 0..=(ntype) {
686 let outcome = run_phase(
687 phase,
688 ntype,
689 ntype_with_fixed,
690 total_phases,
691 max_loops,
692 self.discale,
693 self.precision,
694 !self.perturb,
695 &movebad_cfg,
696 &gencan_params,
697 &mut sys,
698 &mut x,
699 &mut swap,
700 &mut relaxer_runners,
701 &mut handlers,
702 &mut gencan_workspace,
703 &mut rng,
704 );
705 match outcome {
706 PhaseOutcome::Continue => {}
707 PhaseOutcome::Converged => {
708 converged = true;
709 break;
710 }
711 }
712 }
713
714 if !converged {
715 log::warn!(
716 " Pack did not fully converge (fdist={:.4e}, frest={:.4e})",
717 sys.fdist,
718 sys.frest
719 );
720 }
721
722 // Rebuild final xcart from x (all types active)
723 for itype in 0..ntype_with_fixed {
724 sys.comptype[itype] = true;
725 }
726 sys.ntotmol = ntotmol_free;
727 init_xcart_from_x(&x, &mut sys);
728
729 // Notify handlers of final state
730 for h in handlers.iter_mut() {
731 h.on_finish(&sys);
732 }
733
734 // Assemble the topology-complete result frame: replay each target's
735 // template onto the packed coordinates (shared Rust core, so every
736 // language binding gets an identical frame). Stamp the periodic box.
737 let xcart = std::mem::take(&mut sys.xcart);
738 let positions = positions_in_target_order(targets, &xcart, ntotat_free);
739 let mut frame = crate::assemble::assemble_frame(targets, &positions);
740 if let Some((min, max, flags)) = pbc {
741 let lengths = Array1::from_vec(vec![max[0] - min[0], max[1] - min[1], max[2] - min[2]]);
742 let origin = Array1::from_vec(min.to_vec());
743 if let Ok(simbox) = SimBox::ortho(lengths, origin, flags) {
744 frame.simbox = Some(simbox);
745 }
746 }
747 if self.log_level.is_enabled() {
748 handlers.pop();
749 }
750 self.handlers = handlers;
751
752 Ok(PackResult {
753 frame,
754 fdist: sys.fdist,
755 frest: sys.frest,
756 converged,
757 })
758 }
759}
760
761/// Resolved periodic-box spec: `(min, max, periodic_flags)`. Shared by
762/// `derive_periodic_box` and its callers.
763type PeriodicSpec = ([F; 3], [F; 3], [bool; 3]);
764
765/// Scan every restraint on every target for a `AtomRestraint::periodic_box`
766/// override. Returns `Ok(None)` if no restraint declares one, `Ok(Some(...))`
767/// if exactly one unique declaration exists (duplicates with identical
768/// bounds + flags are allowed — they come from `with_global_restraint`
769/// broadcast and from two targets sharing the same restraint object).
770/// Returns `Err(ConflictingPeriodicBoxes)` when two declarations disagree
771/// and `Err(InvalidPBCBox)` if the declared box has a non-positive extent
772/// on any axis.
773fn derive_periodic_box(targets: &[Target]) -> Result<Option<PeriodicSpec>, PackError> {
774 let mut found: Option<PeriodicSpec> = None;
775 for target in targets {
776 let restraints = target
777 .molecule_restraints
778 .iter()
779 .chain(target.atom_restraints.iter().map(|(_, r)| r));
780 for r in restraints {
781 if let Some(candidate) = r.periodic_box() {
782 let (min, max, _periodic) = candidate;
783 let length = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
784 if length.iter().any(|&v| v <= 0.0) {
785 return Err(PackError::InvalidPBCBox { min, max });
786 }
787 match found {
788 None => found = Some(candidate),
789 Some(existing) if existing == candidate => {}
790 Some(existing) => {
791 return Err(PackError::ConflictingPeriodicBoxes {
792 first: existing,
793 second: candidate,
794 });
795 }
796 }
797 }
798 }
799 }
800 Ok(found)
801}
802
803fn reference_coords(target: &Target) -> &[[F; 3]] {
804 match target.centering {
805 CenteringMode::Center => &target.ref_coords,
806 CenteringMode::Off => &target.input_coords,
807 CenteringMode::Auto => {
808 if target.fixed_at.is_some() {
809 &target.input_coords
810 } else {
811 &target.ref_coords
812 }
813 }
814 }
815}
816
817/// Reorder packed coordinates from the packer's internal `xcart` layout
818/// (all free targets first, then all fixed targets) into the target-declared
819/// order that [`crate::assemble::assemble_frame`] expects (target-by-target,
820/// copy-by-copy, atom-by-atom).
821///
822/// Without this, a fixed target declared *before* a free target — e.g. a
823/// `fixed` protein in a solvation box — has its topology replayed onto the
824/// free atoms' coordinates, scrambling the rigid molecule. A no-op when no
825/// target is fixed (the free blocks are already in declared order).
826fn positions_in_target_order(
827 targets: &[Target],
828 xcart: &[[F; 3]],
829 n_free_atoms: usize,
830) -> Vec<[F; 3]> {
831 let mut out = Vec::with_capacity(xcart.len());
832 let mut free_cursor = 0usize;
833 let mut fixed_cursor = n_free_atoms;
834 for t in targets {
835 if t.fixed_at.is_some() {
836 let n = t.natoms();
837 out.extend_from_slice(&xcart[fixed_cursor..fixed_cursor + n]);
838 fixed_cursor += n;
839 } else {
840 let n = t.count * t.natoms();
841 out.extend_from_slice(&xcart[free_cursor..free_cursor + n]);
842 free_cursor += n;
843 }
844 }
845 out
846}
847
848/// Evaluate the packing objective once under **unscaled** radii (`radius_ini`),
849/// restoring the caller's `radius` values on return.
850///
851/// On return, `sys.fdist` / `sys.frest` / `sys.fdist_atom` / `sys.frest_atom`
852/// reflect the unscaled evaluation (the radius-dependent inner state); only
853/// `sys.radius` itself is rolled back to what it was on entry.
854///
855/// Returns `(f_total, fdist, frest)` from the unscaled evaluation — the exact
856/// triple the packer's main loop feeds to `flast` / `fimp` / handler `StepInfo`.
857///
858/// Pulled out of `pack()` in phase A.4.1 to de-duplicate three inline copies
859/// of the same swap-evaluate-restore dance (Packmol `computef` emulation).
860pub fn evaluate_unscaled(sys: &mut PackContext, xwork: &[F]) -> (F, F, F) {
861 sys.work.radiuswork.copy_from_slice(&sys.radius);
862 for i in 0..sys.ntotat {
863 sys.set_radius(i, sys.radius_ini[i]);
864 }
865 let f_total = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
866 let fdist = sys.fdist;
867 let frest = sys.frest;
868 for i in 0..sys.ntotat {
869 sys.set_radius(i, sys.work.radiuswork[i]);
870 }
871 (f_total, fdist, frest)
872}
873
874/// Outcome of one main-loop iteration inside a packing phase.
875///
876/// Pulled out of `pack()` in phase A.4.3 to isolate the ~140-line per-iteration
877/// body that runs movebad → relaxers → pgencan → radii schedule. `Continue`
878/// means "run the next iteration"; `Converged` means the convergence predicate
879/// fired inside this iteration; `EarlyStop` means a `Handler::should_stop()`
880/// returned true.
881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
882pub enum IterOutcome {
883 Continue,
884 Converged,
885 EarlyStop,
886}
887
888/// Run one iteration of a packing phase's main loop.
889///
890/// Matches Packmol's per-iteration sequence in `app/packmol.f90` lines 815-948:
891///
892/// 1. `movebad` when `radscale == 1.0` and previous `fimp <= 10%` (unless
893/// disabled).
894/// 2. Per-target relaxer MC block.
895/// 3. `pgencan` on the working coordinate vector.
896/// 4. Unscaled-radii statistics (`fdist` / `frest` / `fimp`).
897/// 5. Handler `on_step` notification; early stop if any handler opts in.
898/// 6. Convergence check (`fdist < precision && frest < precision`).
899/// 7. Radii reduction schedule (only when `radscale > 1.0`).
900///
901/// The function takes each piece of mutable outer-loop state by `&mut` so the
902/// caller (the outer phase for-loop in `pack()`) retains ownership across
903/// iterations.
904#[allow(clippy::too_many_arguments)]
905pub fn run_iteration(
906 loop_idx: usize,
907 max_loops: usize,
908 is_all: bool,
909 phase: usize,
910 phase_info: PhaseInfo,
911 precision: F,
912 disable_movebad: bool,
913 movebad_cfg: &MoveBadConfig,
914 gencan_params: &GencanParams,
915 sys: &mut PackContext,
916 xwork: &mut [F],
917 swap: &mut SwapState,
918 flast: &mut F,
919 fimp_prev: &mut F,
920 radscale: &mut F,
921 relaxer_runners: &mut Vec<(usize, Vec<Box<dyn RelaxerRunner>>)>,
922 handlers: &mut [Box<dyn Handler>],
923 gencan_workspace: &mut GencanWorkspace,
924 rng: &mut SmallRng,
925) -> IterOutcome {
926 // movebad: Packmol triggers when radscale==1.0 AND fimp<=10.0
927 // (packmol.f90 line 815). fimp here is from the PREVIOUS iteration.
928 // After movebad, reset flast to the post-movebad f (Packmol line 821).
929 if !disable_movebad && *radscale == 1.0 && *fimp_prev <= 10.0 {
930 movebad(xwork, sys, precision, movebad_cfg, rng, gencan_workspace);
931 // Reset flast to the post-movebad f value so fimp is measured
932 // relative to movebad's starting point.
933 *flast = evaluate_unscaled(sys, xwork).0;
934 }
935
936 // Relaxer MC block: run per-target relaxers between movebad and pgencan.
937 // Each relaxer modifies the reference coords (coor) for its type.
938 for (itype, runners) in relaxer_runners.iter_mut() {
939 if !is_all && *itype != phase {
940 continue;
941 }
942
943 let base = sys.idfirst[*itype];
944 let na = sys.natoms[*itype];
945
946 for runner in runners.iter_mut() {
947 let saved: Vec<[F; 3]> = sys.coor[base..base + na].to_vec();
948 let f_before = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
949
950 let result = runner.on_iter(
951 &saved,
952 f_before,
953 &mut |trial: &[[F; 3]]| {
954 sys.coor[base..base + na].copy_from_slice(trial);
955 let f = sys.evaluate(xwork, EvalMode::FOnly, None).f_total;
956 sys.coor[base..base + na].copy_from_slice(&saved);
957 f
958 },
959 rng,
960 );
961
962 if let Some(new_coords) = result {
963 sys.coor[base..base + na].copy_from_slice(&new_coords);
964 }
965 }
966 }
967
968 // GENCAN on working x (compact for per-type, full for all-type)
969 sys.reset_eval_counters();
970 let res = pgencan(xwork, sys, gencan_params, precision, gencan_workspace);
971
972 // Save compact results back to swap (for restore later)
973 if !is_all {
974 swap.save_type(phase, xwork, sys);
975 }
976
977 // Compute statistics with unscaled radii
978 // (Packmol lines 833-841: radiuswork + computef + restore)
979 let (fx_unscaled, fdist, frest) = evaluate_unscaled(sys, xwork);
980
981 // fimp: percentage improvement in unscaled f from last iteration
982 // Packmol line 846: if(flast>0) fimp = -100*(fx-flast)/flast
983 let mut fimp = if *flast > 0.0 {
984 -100.0 * (fx_unscaled - *flast) / *flast
985 } else if fx_unscaled < objective_small_floor() {
986 100.0 // already converged
987 } else {
988 F::INFINITY
989 };
990 // Packmol lines 848-849: clamp to [-99.99, 99.99]
991 fimp = fimp.clamp(-99.99, 99.99);
992 *flast = fx_unscaled;
993 *fimp_prev = fimp;
994
995 if !handlers.is_empty() {
996 let relaxer_acceptance: Vec<(usize, F)> = relaxer_runners
997 .iter()
998 .flat_map(|(itype, runners)| runners.iter().map(move |r| (*itype, r.acceptance_rate())))
999 .collect();
1000
1001 let step_info = StepInfo {
1002 loop_idx,
1003 max_loops,
1004 phase: phase_info,
1005 fdist,
1006 frest,
1007 improvement_pct: fimp,
1008 radscale: *radscale,
1009 precision,
1010 relaxer_acceptance,
1011 };
1012 for h in handlers.iter_mut() {
1013 h.on_step(&step_info, sys);
1014 }
1015
1016 if handlers.iter().any(|h| h.should_stop()) {
1017 log::debug!(" Early stop requested at loop {loop_idx}");
1018 return IterOutcome::EarlyStop;
1019 }
1020 }
1021
1022 log::debug!(
1023 " loop={loop_idx} f={:.4e} fdist={:.4e} frest={:.4e} radscale={:.4} fimp={:.2}% ncf={} ncg={} inform={}",
1024 res.f,
1025 fdist,
1026 frest,
1027 *radscale,
1028 fimp,
1029 sys.ncf(),
1030 sys.ncg(),
1031 res.inform
1032 );
1033
1034 // Check convergence
1035 if fdist < precision && frest < precision {
1036 log::debug!(" Converged at phase {phase} loop {loop_idx}");
1037 return IterOutcome::Converged;
1038 }
1039
1040 // Radii reduction schedule (Packmol lines 940-948):
1041 // if (fdist<precision && fimp<10%) || fimp<2%: reduce radscale
1042 if *radscale > 1.0 && (fimp < 2.0 || (fdist < precision && fimp < 10.0)) {
1043 *radscale = (0.9 * *radscale).max(1.0);
1044 for i in 0..sys.ntotat {
1045 let new_r = sys.radius_ini[i].max(0.9 * sys.radius[i]);
1046 sys.set_radius(i, new_r);
1047 }
1048 }
1049
1050 IterOutcome::Continue
1051}
1052
1053/// Outcome of one outer-loop phase in `pack()`.
1054///
1055/// Pulled out of `pack()` in phase A.4.2 to isolate the outer per-phase scaffold
1056/// (handler phase-start notification, comptype reconfiguration, radii reset,
1057/// swap setup, pre-loop precision short-circuit, inner GENCAN loop, swap
1058/// restore / xwork-back copy). `Continue` means the outer phase loop should
1059/// proceed; `Converged` means the all-type phase converged and the outer loop
1060/// should break.
1061#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1062pub enum PhaseOutcome {
1063 Continue,
1064 Converged,
1065}
1066
1067/// Run one phase of the main packing loop (per-type or all-type).
1068///
1069/// Matches the outer `for phase in 0..=ntype` body of Packmol `app/packmol.f90`
1070/// lines 740-990 (the swaptype / comptype dance bracketing the GENCAN inner
1071/// loop). For a per-type phase (`phase < ntype`), `xwork` is a compact
1072/// `nmols[phase] * 6`-element slice produced by `SwapState::set_type`; for the
1073/// all-type phase (`phase == ntype`), `xwork` is a full `6 * ntotmol_free`
1074/// clone of `x`.
1075///
1076/// The function takes the outer-loop state (`sys`, `x`, `swap`,
1077/// `relaxer_runners`, `handlers`, `gencan_workspace`, `rng`) by `&mut` so that
1078/// state persists across phases, exactly as the inlined body did.
1079///
1080/// Returns `PhaseOutcome::Converged` **only** when the all-type phase
1081/// converges (either on its entry precision check or inside the inner loop);
1082/// every per-type phase returns `Continue` regardless of whether that type
1083/// converged on its own (Packmol lets the all-type phase decide).
1084#[allow(clippy::too_many_arguments)]
1085pub fn run_phase(
1086 phase: usize,
1087 ntype: usize,
1088 ntype_with_fixed: usize,
1089 total_phases: usize,
1090 max_loops: usize,
1091 discale: F,
1092 precision: F,
1093 disable_movebad: bool,
1094 movebad_cfg: &MoveBadConfig,
1095 gencan_params: &GencanParams,
1096 sys: &mut PackContext,
1097 x: &mut Vec<F>,
1098 swap: &mut SwapState,
1099 relaxer_runners: &mut Vec<(usize, Vec<Box<dyn RelaxerRunner>>)>,
1100 handlers: &mut [Box<dyn Handler>],
1101 gencan_workspace: &mut GencanWorkspace,
1102 rng: &mut SmallRng,
1103) -> PhaseOutcome {
1104 let is_all = phase == ntype;
1105
1106 let phase_info = PhaseInfo {
1107 phase,
1108 total_phases,
1109 molecule_type: if is_all { None } else { Some(phase) },
1110 };
1111
1112 // Reset handler state between phases (e.g. EarlyStopHandler stall counter)
1113 for h in handlers.iter_mut() {
1114 h.on_phase_start(&phase_info);
1115 }
1116
1117 // Set comptype for this phase
1118 for itype in 0..ntype_with_fixed {
1119 sys.comptype[itype] = if is_all {
1120 true
1121 } else {
1122 itype >= ntype || itype == phase
1123 };
1124 }
1125
1126 log::debug!(
1127 " Packing phase {phase} ({})",
1128 if is_all {
1129 "all".to_string()
1130 } else {
1131 format!("type {phase}")
1132 }
1133 );
1134
1135 // Compact x to this type (action=1) or restore full x (all-type phase)
1136 // Packmol resets radscale = discale at the START of each phase.
1137 let mut radscale = discale;
1138 for icart in 0..sys.ntotat {
1139 sys.set_radius(icart, discale * sys.radius_ini[icart]);
1140 }
1141
1142 // Get working x vector (compact for per-type, full for all-type)
1143 let mut xwork: Vec<F> = if !is_all {
1144 // Compact: n = nmols[phase] * 6
1145 // Re-save current x (action=0) then compact (action=1)
1146 *swap = SwapState::init(x, sys);
1147 swap.set_type(phase, sys)
1148 } else {
1149 // All-type: restore full x (action=3), use it directly
1150 swap.restore(x, sys);
1151 x.clone()
1152 };
1153
1154 // Packmol checks whether the current approximation is already a solution
1155 // before entering the GENCAN loop for this phase (packmol.f90 lines 775-782).
1156 sys.evaluate(&xwork, EvalMode::FOnly, None);
1157 if sys.fdist < precision && sys.frest < precision {
1158 let report = PhaseReport {
1159 iterations: 0,
1160 fdist: sys.fdist,
1161 frest: sys.frest,
1162 converged: true,
1163 };
1164 for h in handlers.iter_mut() {
1165 h.on_phase_end(&phase_info, &report);
1166 }
1167 if !is_all {
1168 swap.save_type(phase, &xwork, sys);
1169 swap.restore(x, sys);
1170 return PhaseOutcome::Continue;
1171 } else {
1172 x.clone_from(&xwork);
1173 return PhaseOutcome::Converged;
1174 }
1175 }
1176
1177 // Initialize flast = unscaled f before gencanloop
1178 // (Packmol lines 796-803: compute bestf/flast with unscaled radii)
1179 let mut flast = evaluate_unscaled(sys, &xwork).0;
1180
1181 // fimp from previous iteration — used for movebad gate (Packmol packmol.f90 line 798).
1182 // Initialized to 1e99 so movebad is NOT called on the first iteration.
1183 let mut fimp_prev = F::INFINITY;
1184 let mut converged_inner = false;
1185 let mut iterations = 0usize;
1186
1187 for loop_idx in 0..max_loops {
1188 let outcome = run_iteration(
1189 loop_idx,
1190 max_loops,
1191 is_all,
1192 phase,
1193 phase_info,
1194 precision,
1195 disable_movebad,
1196 movebad_cfg,
1197 gencan_params,
1198 sys,
1199 &mut xwork,
1200 swap,
1201 &mut flast,
1202 &mut fimp_prev,
1203 &mut radscale,
1204 relaxer_runners,
1205 handlers,
1206 gencan_workspace,
1207 rng,
1208 );
1209 iterations += 1;
1210 match outcome {
1211 IterOutcome::Continue => {}
1212 IterOutcome::Converged => {
1213 converged_inner = true;
1214 break;
1215 }
1216 IterOutcome::EarlyStop => break,
1217 }
1218 }
1219
1220 let report = PhaseReport {
1221 iterations,
1222 fdist: sys.fdist,
1223 frest: sys.frest,
1224 converged: converged_inner,
1225 };
1226 for h in handlers.iter_mut() {
1227 h.on_phase_end(&phase_info, &report);
1228 }
1229
1230 // After per-type phase: save results + restore full x
1231 // After all-type phase: copy xwork back to x
1232 if !is_all {
1233 // save_type was called inside the loop; restore full x now.
1234 // Per-type convergence does NOT exit the outer phase loop.
1235 swap.restore(x, sys);
1236 PhaseOutcome::Continue
1237 } else {
1238 x.clone_from(&xwork);
1239 if converged_inner {
1240 PhaseOutcome::Converged
1241 } else {
1242 PhaseOutcome::Continue
1243 }
1244 }
1245}
1246
1247#[cfg(test)]
1248mod reorder_tests {
1249 use super::*;
1250
1251 #[test]
1252 fn fixed_target_declared_first_maps_to_its_xcart_block() {
1253 // Target A: fixed, 2 atoms, declared first.
1254 // Target B: free, 3 atoms x 2 copies = 6 atoms.
1255 // Internal xcart is free-first/fixed-last: [B(0..6) | A(6..8)].
1256 let a = Target::from_coords(&[[0.0; 3]; 2], &[1.0; 2], 1).fixed_at([0.0, 0.0, 0.0]);
1257 let b = Target::from_coords(&[[0.0; 3]; 3], &[1.0; 3], 2);
1258 let targets = vec![a, b];
1259 // x-coordinate encodes the xcart slot index, so we can read the mapping.
1260 let xcart: Vec<[F; 3]> = (0..8).map(|i| [i as F, 0.0, 0.0]).collect();
1261 let out = positions_in_target_order(&targets, &xcart, 6);
1262 let x: Vec<F> = out.iter().map(|p| p[0]).collect();
1263 // Declared order: A's fixed block (slots 6,7) then B's free block (0..6).
1264 assert_eq!(x, vec![6.0, 7.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
1265 }
1266
1267 #[test]
1268 fn no_fixed_targets_is_identity() {
1269 let a = Target::from_coords(&[[0.0; 3]; 2], &[1.0; 2], 1);
1270 let b = Target::from_coords(&[[0.0; 3]; 3], &[1.0; 3], 2);
1271 let targets = vec![a, b];
1272 let xcart: Vec<[F; 3]> = (0..8).map(|i| [i as F, 0.0, 0.0]).collect();
1273 let out = positions_in_target_order(&targets, &xcart, 8);
1274 let x: Vec<F> = out.iter().map(|p| p[0]).collect();
1275 assert_eq!(x, (0..8).map(|i| i as F).collect::<Vec<_>>());
1276 }
1277}