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