Skip to main content

xtalforge_core/
limits.rs

1//! Size limits: arithmetic estimates made before anything is allocated.
2//!
3//! Two levels: a soft limit warns, a hard limit refuses. Profiles are
4//! `Standard` (8 GB class), `Generous` (every limit doubled) and `WarnOnly`
5//! (never refuses). The numbers are the Crystal-Builder table (Task 1.3).
6
7use std::fmt;
8
9use crate::Error;
10use crate::lattice::Lattice;
11
12/// Undo history sites allowed in whole-structure steps.
13pub const UNDO_ATOMS: u64 = 2_000_000;
14/// Most recent undo steps that are never dropped.
15pub const UNDO_KEEP: u64 = 5;
16/// Analytic Hessian memory estimate per atom, in MiB.
17pub const HESSIAN_MIB_PER_ATOM: f64 = 13.0;
18
19/// Limit profile.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum Profile {
22    /// 8 GB-class machine.
23    #[default]
24    Standard,
25    /// Every limit doubled.
26    Generous,
27    /// Warn only; never refuses.
28    WarnOnly,
29}
30
31impl Profile {
32    /// Parses a profile name, falling back to Standard for unknown values.
33    #[must_use]
34    pub fn parse(name: &str) -> Self {
35        match name {
36            "generous" => Self::Generous,
37            "warn_only" | "warn-only" => Self::WarnOnly,
38            _ => Self::Standard,
39        }
40    }
41
42    /// Stable profile name.
43    #[must_use]
44    pub const fn name(self) -> &'static str {
45        match self {
46            Self::Standard => "standard",
47            Self::Generous => "generous",
48            Self::WarnOnly => "warn_only",
49        }
50    }
51}
52
53/// A size budget.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Budget {
56    /// Atoms drawn for a display range.
57    Drawn,
58    /// Atoms in a supercell.
59    Supercell,
60    /// Points in a porosity grid.
61    Grid,
62    /// Atoms in a carbon build.
63    Carbon,
64    /// Atoms in a nanotube build.
65    Nanotube,
66    /// Atoms in a graphene build.
67    Graphene,
68    /// Atoms in a phonon supercell.
69    Phonons,
70    /// Atoms in an analytic Hessian.
71    Hessian,
72}
73
74impl Budget {
75    /// Parses a budget name; unknown names are an error.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`Error::InvalidInput`] for an unknown budget.
80    pub fn parse(name: &str) -> Result<Self, Error> {
81        match name {
82            "drawn" => Ok(Self::Drawn),
83            "supercell" => Ok(Self::Supercell),
84            "grid" => Ok(Self::Grid),
85            "carbon" => Ok(Self::Carbon),
86            "nanotube" => Ok(Self::Nanotube),
87            "graphene" => Ok(Self::Graphene),
88            "phonons" => Ok(Self::Phonons),
89            "hessian" => Ok(Self::Hessian),
90            _ => Err(Error::InvalidInput(format!("unknown limit budget: {name}"))),
91        }
92    }
93
94    fn table(self) -> (u64, u64) {
95        match self {
96            Self::Drawn => (100_000, 400_000),
97            Self::Supercell => (50_000, 200_000),
98            Self::Grid => (1_500_000, 4_000_000),
99            Self::Carbon => (10_000, 30_000),
100            Self::Nanotube => (50_000, 200_000),
101            Self::Graphene => (50_000, 200_000),
102            Self::Phonons => (2_000, 8_000),
103            Self::Hessian => (100, 300),
104        }
105    }
106}
107
108/// Limit level of a verdict.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum LimitLevel {
111    /// Inside the soft limit.
112    Ok,
113    /// Over the soft limit but under the hard limit.
114    Warn,
115    /// Over the hard limit.
116    Refuse,
117}
118
119/// Result of a size check.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Verdict {
122    /// Limit level.
123    pub level: LimitLevel,
124    /// Estimated count.
125    pub estimate: u64,
126    /// Human-readable sentence; empty when the estimate fits.
127    pub sentence: String,
128}
129
130impl LimitLevel {
131    /// Stable level name.
132    #[must_use]
133    pub const fn name(self) -> &'static str {
134        match self {
135            Self::Ok => "ok",
136            Self::Warn => "warn",
137            Self::Refuse => "refuse",
138        }
139    }
140}
141
142impl Verdict {
143    /// Whether the operation is refused.
144    #[must_use]
145    pub fn refused(&self) -> bool {
146        self.level == LimitLevel::Refuse
147    }
148
149    /// Whether the operation is warned or refused.
150    #[must_use]
151    pub fn warned(&self) -> bool {
152        self.level != LimitLevel::Ok
153    }
154}
155
156impl fmt::Display for Verdict {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        if self.sentence.is_empty() {
159            write!(formatter, "{}", self.estimate)
160        } else {
161            formatter.write_str(&self.sentence)
162        }
163    }
164}
165
166/// Limits model with a profile.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
168pub struct Limits {
169    profile: Profile,
170}
171
172impl Limits {
173    /// Creates limits with a profile.
174    #[must_use]
175    pub const fn new(profile: Profile) -> Self {
176        Self { profile }
177    }
178
179    /// Profile used by these limits.
180    #[must_use]
181    pub const fn profile(&self) -> Profile {
182        self.profile
183    }
184
185    /// `(soft, hard)` for a budget; `hard` is `None` under `WarnOnly`.
186    #[must_use]
187    pub fn bounds(&self, budget: Budget) -> (u64, Option<u64>) {
188        let (soft, hard) = budget.table();
189        match self.profile {
190            Profile::Standard => (soft, Some(hard)),
191            Profile::Generous => (2 * soft, Some(2 * hard)),
192            Profile::WarnOnly => (soft, None),
193        }
194    }
195
196    /// Judges an estimate against a budget.
197    #[must_use]
198    pub fn judge(
199        &self,
200        budget: Budget,
201        estimate: u64,
202        what: &str,
203        fits: impl Fn(u64) -> String,
204    ) -> Verdict {
205        let (soft, hard) = self.bounds(budget);
206        if estimate <= soft {
207            return Verdict {
208                level: LimitLevel::Ok,
209                estimate,
210                sentence: String::new(),
211            };
212        }
213        if let Some(hard) = hard
214            && estimate > hard
215        {
216            return Verdict {
217                level: LimitLevel::Refuse,
218                estimate,
219                sentence: format!(
220                    "{} over the {} the Large structures setting allows; {}",
221                    what.replace("{}", &count(estimate)),
222                    count(hard),
223                    fits(hard)
224                ),
225            };
226        }
227        Verdict {
228            level: LimitLevel::Warn,
229            estimate,
230            sentence: format!(
231                "{} over the {} that is comfortable; {}",
232                what.replace("{}", &count(estimate)),
233                count(soft),
234                fits(soft)
235            ),
236        }
237    }
238}
239
240/// Formats a count as a sentence says it: `52,000` or `1.4 M`.
241#[must_use]
242pub fn count(value: u64) -> String {
243    if value >= 1_000_000 {
244        format!("{:.1} M", value as f64 / 1e6)
245    } else {
246        let text = value.to_string();
247        let mut out = String::new();
248        for (index, character) in text.chars().rev().enumerate() {
249            if index > 0 && index % 3 == 0 {
250                out.push(',');
251            }
252            out.push(character);
253        }
254        out.chars().rev().collect()
255    }
256}
257
258/// Bounds for a profile and budget name.
259///
260/// # Errors
261///
262/// Returns [`Error::InvalidInput`] for an unknown budget.
263pub fn limits_bounds(profile: &str, budget: &str) -> Result<(u64, Option<u64>), Error> {
264    let limits = Limits::new(Profile::parse(profile));
265    Ok(limits.bounds(Budget::parse(budget)?))
266}
267
268/// Generic limit check by profile, budget and estimate.
269///
270/// # Errors
271///
272/// Returns [`Error::InvalidInput`] for an unknown budget.
273pub fn limits_check(profile: &str, budget: &str, estimate: u64) -> Result<Verdict, Error> {
274    let limits = Limits::new(Profile::parse(profile));
275    let budget = Budget::parse(budget)?;
276    Ok(limits.judge(
277        budget,
278        estimate,
279        "the operation would have {} items",
280        |limit| format!("up to {} items fit", count(limit)),
281    ))
282}
283
284/// Atoms drawn for a display range: cell atoms times the range volume.
285#[must_use]
286pub fn drawn_atoms(n_atoms: u64, ranges: [(f64, f64); 3]) -> u64 {
287    let volume = ranges
288        .iter()
289        .map(|(low, high)| (high - low).max(0.0))
290        .product::<f64>();
291    (n_atoms as f64 * volume).round() as u64
292}
293
294/// `|det P|` for a 3x3 transformation matrix.
295///
296/// # Errors
297///
298/// Returns [`Error::InvalidInput`] for a singular matrix or non-finite values.
299pub fn multiplier(p: [[f64; 3]; 3]) -> Result<u64, Error> {
300    if !p.iter().flatten().all(|value| value.is_finite()) {
301        return Err(Error::InvalidInput(
302            "transformation matrix contains non-finite values".to_owned(),
303        ));
304    }
305    let [[a, b, c], [d, e, f], [g, h, i]] = p;
306    let determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
307    if determinant.abs() < 1e-9 {
308        return Err(Error::InvalidInput(
309            "transformation matrix is singular".to_owned(),
310        ));
311    }
312    Ok(determinant.abs().round() as u64)
313}
314
315/// Atoms in the cell a transformation builds from `n_atoms`.
316///
317/// # Errors
318///
319/// Returns the errors of [`multiplier`].
320pub fn supercell_atoms(n_atoms: u64, p: [[f64; 3]; 3]) -> Result<u64, Error> {
321    Ok(n_atoms.saturating_mul(multiplier(p)?))
322}
323
324/// Grid shape from cell lengths and a target spacing.
325///
326/// # Errors
327///
328/// Returns [`Error::InvalidInput`] for a non-positive or non-finite spacing.
329pub fn grid_shape(lattice: &Lattice, spacing: f64) -> Result<[u32; 3], Error> {
330    if !spacing.is_finite() || spacing <= 0.0 {
331        return Err(Error::InvalidInput(format!(
332            "grid spacing must be positive and finite, got {spacing}"
333        )));
334    }
335    let spacing = spacing.max(0.05);
336    Ok(lattice
337        .lengths()
338        .map(|length| (length / spacing).round().max(8.0) as u32))
339}
340
341/// Points in a porosity grid.
342///
343/// # Errors
344///
345/// Returns the errors of [`grid_shape`].
346pub fn grid_points(lattice: &Lattice, spacing: f64) -> Result<u64, Error> {
347    let [a, b, c] = grid_shape(lattice, spacing)?;
348    Ok(u64::from(a) * u64::from(b) * u64::from(c))
349}
350
351fn cube_that_fits(per_cell: f64, limit: u64, cells: &str) -> String {
352    let k = ((limit as f64 / per_cell.max(1.0)).powf(1.0 / 3.0) + 1e-9).floor() as u64;
353    if k < 1 {
354        format!("one cell alone is {} atoms", count(per_cell.round() as u64))
355    } else {
356        format!("{k} x {k} x {k}{cells} is the largest that fits")
357    }
358}
359
360/// Checks the atoms a display range would draw.
361#[must_use]
362pub fn check_drawn(limits: &Limits, n_atoms: u64, ranges: [(f64, f64); 3]) -> Verdict {
363    let estimate = drawn_atoms(n_atoms, ranges);
364    limits.judge(
365        Budget::Drawn,
366        estimate,
367        "the picture would draw {} atoms",
368        |limit| cube_that_fits(n_atoms as f64, limit, " cells"),
369    )
370}
371
372/// Checks a supercell transformation.
373///
374/// # Errors
375///
376/// Returns the errors of [`supercell_atoms`].
377pub fn check_supercell(limits: &Limits, n_atoms: u64, p: [[f64; 3]; 3]) -> Result<Verdict, Error> {
378    let estimate = supercell_atoms(n_atoms, p)?;
379    Ok(limits.judge(
380        Budget::Supercell,
381        estimate,
382        "the new cell would have {} atoms",
383        |limit| cube_that_fits(n_atoms as f64, limit, ""),
384    ))
385}
386
387/// Checks a porosity grid.
388///
389/// # Errors
390///
391/// Returns the errors of [`grid_points`].
392pub fn check_grid(limits: &Limits, lattice: &Lattice, spacing: f64) -> Result<Verdict, Error> {
393    let estimate = grid_points(lattice, spacing)?;
394    Ok(limits.judge(
395        Budget::Grid,
396        estimate,
397        "the grid would have {} points",
398        |limit| {
399            let mut step = (spacing / 0.05).ceil() * 0.05;
400            step = step.max(0.05);
401            while grid_points(lattice, step).unwrap_or(u64::MAX) > limit && step < 5.0 {
402                step += 0.05;
403            }
404            format!(
405                "at {step:.2} A it would have {}",
406                grid_points(lattice, step)
407                    .map(count)
408                    .unwrap_or_else(|_| "an invalid count".to_owned())
409            )
410        },
411    ))
412}
413
414/// Checks a phonon supercell.
415///
416/// # Errors
417///
418/// Returns the errors of [`supercell_atoms`].
419pub fn check_phonons(limits: &Limits, n_atoms: u64, p: [[f64; 3]; 3]) -> Result<Verdict, Error> {
420    let estimate = supercell_atoms(n_atoms, p)?;
421    Ok(limits.judge(
422        Budget::Phonons,
423        estimate,
424        "the phonon supercell would have {} atoms",
425        |limit| {
426            format!(
427                "{}, or Gamma only needs none",
428                cube_that_fits(n_atoms as f64, limit, "")
429            )
430        },
431    ))
432}
433
434/// Checks an analytic Hessian over `n_atoms` free atoms.
435#[must_use]
436pub fn check_hessian(limits: &Limits, n_atoms: u64) -> Verdict {
437    limits.judge(
438        Budget::Hessian,
439        n_atoms,
440        &format!(
441            "an analytic Hessian over {{}} atoms would hold about {:.1} GB",
442            n_atoms as f64 * HESSIAN_MIB_PER_ATOM / 1024.0
443        ),
444        |limit| {
445            format!(
446                "up to {} atoms fit, and finite differences need no more than one evaluation does",
447                count(limit)
448            )
449        },
450    )
451}