1use super::data::{CoordinateStore, StructureData};
15use crate::Structure;
16use crate::coords::CoordinateBlock;
17use crate::diagnostic::{Code, Diagnostic, Diagnostics};
18use crate::index::ResidueIndex;
19
20#[must_use]
24pub fn validate(data: &StructureData) -> Vec<Diagnostic> {
25 let mut findings = Diagnostics::new();
26 let atoms = atoms_in_chunks(data);
27
28 check_child_ranges(data, &mut findings);
29 check_atom_coverage(data, atoms, &mut findings);
30 check_entity_references(data, &mut findings);
31 check_bonds(data, atoms, &mut findings);
32 check_anisotropy(data, atoms, &mut findings);
33 check_annotations(data, &mut findings);
34 check_coordinate_counts(data, atoms, &mut findings);
35 check_coordinates(data, &mut findings);
36 check_occupancies(data, &mut findings);
37
38 findings.finish()
39}
40
41pub(crate) fn validate_coordinate_edit(data: &StructureData) -> Vec<Diagnostic> {
43 let mut findings = Diagnostics::new();
44 let atoms = atoms_in_chunks(data);
45 check_coordinate_counts(data, atoms, &mut findings);
46 check_coordinates(data, &mut findings);
47 findings.finish()
48}
49
50fn atoms_in_chunks(data: &StructureData) -> u32 {
54 data.chunks.last().map_or(0, |chunk| chunk.atoms().end)
55}
56
57fn check_annotations(data: &StructureData, findings: &mut Diagnostics) {
61 let atoms = data.atom_count();
62
63 for (name, column) in data.annotations.iter() {
64 if column.len() == atoms {
65 continue;
66 }
67
68 findings.push(
69 Diagnostic::new(Code::E3011)
70 .with_context("annotation", name)
71 .with_context("column rows", column.len().to_string())
72 .with_context("atoms", atoms.to_string()),
73 );
74 }
75}
76
77fn check_bonds(data: &StructureData, atom_count: u32, findings: &mut Diagnostics) {
82 for (position, bond) in data.bonds.iter().enumerate() {
83 if bond.atom_a == bond.atom_b {
84 findings.push(Diagnostic::new(Code::E3007).with_context("bond", position.to_string()));
85 }
86
87 check_bond_endpoint(position, bond.atom_a.get(), atom_count, findings);
88 check_bond_endpoint(position, bond.atom_b.get(), atom_count, findings);
89 }
90}
91
92fn check_bond_endpoint(bond: usize, atom: u32, atom_count: u32, findings: &mut Diagnostics) {
96 if atom < atom_count {
97 return;
98 }
99
100 findings.push(
101 Diagnostic::new(Code::E3006)
102 .with_context("bond", bond.to_string())
103 .with_context("atom", atom.to_string()),
104 );
105}
106
107fn check_anisotropy(data: &StructureData, atom_count: u32, findings: &mut Diagnostics) {
112 for (position, record) in data.anisotropy.iter().enumerate() {
113 if record.atom.get() < atom_count {
114 continue;
115 }
116 findings.push(
117 Diagnostic::new(Code::E3006)
118 .with_context("anisotropy", position.to_string())
119 .with_context("atom", record.atom.get().to_string()),
120 );
121 }
122}
123
124fn check_coordinates(data: &StructureData, findings: &mut Diagnostics) {
129 match &data.coords {
130 CoordinateStore::Single(block) => {
131 check_coordinate_block(data, block, 0, findings);
132 }
133 CoordinateStore::Dense { frames } => {
134 for (model, block) in frames.iter().enumerate() {
135 let Ok(model) = u32::try_from(model) else {
136 findings
137 .push(Diagnostic::new(Code::E3001).with_context("model", "exceeds u32"));
138 return;
139 };
140
141 check_coordinate_block(data, block, model, findings);
142 }
143 }
144 CoordinateStore::Ragged { models } => {
145 check_ragged_models(models, findings);
146 }
147 }
148}
149
150fn check_coordinate_block(
155 data: &StructureData,
156 block: &CoordinateBlock,
157 model: u32,
158 findings: &mut Diagnostics,
159) {
160 let positions = block.as_slice();
161
162 for chunk in data.chunks.iter() {
163 let start = chunk.atoms().start;
164
165 for local in 0..chunk.len() {
166 if !chunk.has_position(local) {
167 continue;
168 }
169
170 let Some(atom) = start.checked_add(local) else {
171 findings.push(
172 Diagnostic::new(Code::E3001)
173 .with_context("model", model.to_string())
174 .with_context("atom", "index overflow"),
175 );
176 continue;
177 };
178
179 let Some(position) = positions.get(atom as usize) else {
180 continue;
182 };
183
184 if position.iter().all(|value| value.is_finite()) {
185 continue;
186 }
187
188 findings.push(
189 Diagnostic::new(Code::E3008)
190 .with_context("model", model.to_string())
191 .with_context("atom", atom.to_string()),
192 );
193 }
194 }
195}
196
197fn check_ragged_models(models: &[Structure], findings: &mut Diagnostics) {
202 for (model, structure) in models.iter().enumerate() {
203 for finding in validate(structure.data()) {
204 findings.push(finding.with_context("ragged model", model.to_string()));
205 }
206 }
207}
208
209fn check_child_ranges(data: &StructureData, findings: &mut Diagnostics) {
212 let topology = &data.topology;
213 let shared_topology = matches!(&data.coords, CoordinateStore::Dense { .. });
214
215 let mut expected_chain = 0u32;
216 let mut shared_range: Option<(u32, u32)> = None;
217
218 for model in topology.models.iter() {
219 let Some(range) = topology.models.chains(model) else {
220 findings.push(
221 Diagnostic::new(Code::E3002)
222 .with_message("a model has no chain range")
223 .with_context("model", model.to_string()),
224 );
225 continue;
226 };
227
228 if range.start > range.end {
229 findings.push(
230 Diagnostic::new(Code::E3003)
231 .with_message("a model's chain range runs backwards")
232 .with_context("model", model.to_string()),
233 );
234 }
235
236 if range_exceeds_table(range.start, range.end, topology.chains.len()) {
237 findings.push(
238 Diagnostic::new(Code::E3002)
239 .with_message("a model's chains run past the chain table")
240 .with_context("model", model.to_string()),
241 );
242 }
243
244 if shared_topology {
245 let current = (range.start, range.end);
246
247 match shared_range {
248 Some(expected) if expected != current => {
249 findings.push(
250 Diagnostic::new(Code::E3003)
251 .with_message("dense models do not share one chain range")
252 .with_context("model", model.to_string()),
253 );
254 }
255 None => shared_range = Some(current),
256 Some(_) => {}
257 }
258 } else {
259 if range.start < expected_chain {
260 findings.push(
261 Diagnostic::new(Code::E3003)
262 .with_message("models share chains")
263 .with_context("model", model.to_string()),
264 );
265 }
266
267 expected_chain = expected_chain.max(range.end);
268 }
269 }
270
271 let mut expected_residue = 0u32;
272
273 for chain in topology.chains.iter() {
274 let Some(range) = topology.chains.residues(chain) else {
275 findings.push(
276 Diagnostic::new(Code::E3002)
277 .with_message("a chain has no residue range")
278 .with_context("chain", chain.to_string()),
279 );
280 continue;
281 };
282
283 if range.start > range.end {
284 findings.push(
285 Diagnostic::new(Code::E3003)
286 .with_message("a chain's residue range runs backwards")
287 .with_context("chain", chain.to_string()),
288 );
289 }
290
291 if range_exceeds_table(range.start, range.end, topology.residues.len()) {
292 findings.push(
293 Diagnostic::new(Code::E3002)
294 .with_message("a chain's residues run past the residue table")
295 .with_context("chain", chain.to_string()),
296 );
297 }
298
299 if range.start < expected_residue {
300 findings.push(
301 Diagnostic::new(Code::E3003)
302 .with_message("chains share residues")
303 .with_context("chain", chain.to_string()),
304 );
305 }
306
307 expected_residue = expected_residue.max(range.end);
308 }
309}
310
311fn range_exceeds_table(start: u32, end: u32, table_len: usize) -> bool {
316 let start = usize::try_from(start);
317 let end = usize::try_from(end);
318
319 match (start, end) {
320 (Ok(start), Ok(end)) => start > table_len || end > table_len,
321 _ => true,
322 }
323}
324
325fn check_atom_coverage(data: &StructureData, atoms_in_chunks: u32, findings: &mut Diagnostics) {
328 let residues = &data.topology.residues;
329
330 let Ok(residue_count) = u32::try_from(residues.len()) else {
331 findings.push(Diagnostic::new(Code::E3001).with_context("residues", "exceeds u32"));
332 return;
333 };
334
335 let mut expected = 0u32;
336
337 for position in 0..residue_count {
338 let residue = ResidueIndex::new(position);
339
340 let Some(range) = residues.atoms(residue) else {
341 findings.push(
342 Diagnostic::new(Code::E3004)
343 .with_message("a residue has no atom range")
344 .with_context("residue", position.to_string()),
345 );
346 continue;
347 };
348
349 if range.start > range.end {
350 findings.push(
351 Diagnostic::new(Code::E3004)
352 .with_message("a residue's atom range runs backwards")
353 .with_context("residue", position.to_string()),
354 );
355 }
356
357 if range.start != expected {
358 findings.push(
359 Diagnostic::new(Code::E3004)
360 .with_message("residues do not cover the atoms contiguously")
361 .with_context("residue", position.to_string())
362 .with_context("expected first atom", expected.to_string()),
363 );
364 }
365
366 expected = expected.max(range.end);
367 }
368
369 if expected != atoms_in_chunks {
370 findings.push(
371 Diagnostic::new(Code::E3401)
372 .with_message("the residue table and the atom chunks disagree on the atom count")
373 .with_context("residues account for", expected.to_string())
374 .with_context("chunks hold", atoms_in_chunks.to_string()),
375 );
376 }
377}
378
379fn check_entity_references(data: &StructureData, findings: &mut Diagnostics) {
384 let topology = &data.topology;
385
386 for chain in topology.chains.iter() {
387 let Some(entity) = topology.chains.entity(chain) else {
388 findings.push(
389 Diagnostic::new(Code::E3005)
390 .with_context("chain", chain.to_string())
391 .with_context("entity", "missing"),
392 );
393 continue;
394 };
395
396 if entity.as_usize() < topology.entities.len() {
397 continue;
398 }
399
400 findings.push(
401 Diagnostic::new(Code::E3005)
402 .with_context("chain", chain.to_string())
403 .with_context("entity", entity.to_string()),
404 );
405 }
406}
407
408fn check_coordinate_counts(data: &StructureData, atoms: u32, findings: &mut Diagnostics) {
410 match &data.coords {
411 CoordinateStore::Single(block) => {
412 if block.len() != atoms {
413 findings.push(
414 Diagnostic::new(Code::E3401)
415 .with_context("positions", block.len().to_string())
416 .with_context("atoms", atoms.to_string()),
417 );
418 }
419 }
420 CoordinateStore::Dense { frames } => {
421 for (index, frame) in frames.iter().enumerate() {
422 if frame.len() == atoms {
423 continue;
424 }
425
426 findings.push(
427 Diagnostic::new(Code::E3010)
428 .with_context("model", index.to_string())
429 .with_context("positions", frame.len().to_string())
430 .with_context("atoms", atoms.to_string()),
431 );
432 }
433 }
434 CoordinateStore::Ragged { .. } => {}
435 }
436}
437
438fn check_occupancies(data: &StructureData, findings: &mut Diagnostics) {
440 for chunk in data.chunks.iter() {
441 let start = chunk.atoms().start;
442
443 for local in 0..chunk.len() {
444 let Some((occupancy, presence)) = chunk.occupancy(local) else {
445 continue;
446 };
447
448 if !presence.is_present() || (0.0..=1.0).contains(&occupancy) {
449 continue;
450 }
451
452 let Some(atom) = start.checked_add(local) else {
453 findings.push(Diagnostic::new(Code::E3001).with_context("atom", "index overflow"));
454 continue;
455 };
456
457 findings.push(
458 Diagnostic::new(Code::E3009)
459 .with_context("atom", atom.to_string())
460 .with_context("occupancy", occupancy.to_string()),
461 );
462 }
463 }
464}
465
466#[cfg(test)]
467#[path = "validate_tests.rs"]
468mod tests;