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