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