1use std::sync::Arc;
16
17use axioval_ir::{Evidence, ObjectId};
18
19use crate::services::reviewable_exact_evidence;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
23pub enum GuardError {
24 #[error("guard quantities must be finite and intervals ordered within [0, 1]")]
26 InvalidQuantity,
27 #[error("guard evidence must be exact and reviewable")]
29 InexactEvidence,
30 #[error("guard measurement is unavailable")]
32 Unavailable,
33 #[error("guard search radii must be finite and positive")]
35 InvalidSearch,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq)]
43pub struct GuardSearch {
44 candidate_radius_metres: f64,
45 sample_spacing_metres: f64,
46}
47
48impl GuardSearch {
49 pub fn try_new(
50 candidate_radius_metres: f64,
51 sample_spacing_metres: f64,
52 ) -> Result<Self, GuardError> {
53 let positive = |v: f64| v.is_finite() && v > 0.0;
54 if !positive(candidate_radius_metres) || !positive(sample_spacing_metres) {
55 return Err(GuardError::InvalidSearch);
56 }
57 Ok(Self {
58 candidate_radius_metres,
59 sample_spacing_metres,
60 })
61 }
62 pub fn candidate_radius_metres(&self) -> f64 {
63 self.candidate_radius_metres
64 }
65 pub fn sample_spacing_metres(&self) -> f64 {
66 self.sample_spacing_metres
67 }
68}
69
70#[derive(Clone, Debug, PartialEq)]
72pub struct GuardCandidate {
73 element: ObjectId,
74 horizontal_gap_metres: f64,
75 top_offset_metres: f64,
79 edge_interval: [f64; 2],
81 landing_width_metres: f64,
82 curb_top_offset_metres: Option<f64>,
84}
85
86impl GuardCandidate {
87 pub fn try_new(
88 element: ObjectId,
89 horizontal_gap_metres: f64,
90 top_offset_metres: f64,
91 edge_interval: [f64; 2],
92 landing_width_metres: f64,
93 curb_top_offset_metres: Option<f64>,
94 ) -> Result<Self, GuardError> {
95 let finite = |v: f64| v.is_finite();
96 if !finite(horizontal_gap_metres)
97 || !finite(top_offset_metres)
98 || !finite(landing_width_metres)
99 || horizontal_gap_metres < 0.0
100 || landing_width_metres < 0.0
101 || curb_top_offset_metres.is_some_and(|v| !v.is_finite())
102 {
103 return Err(GuardError::InvalidQuantity);
104 }
105 let [start, end] = edge_interval;
108 if !finite(start) || !finite(end) || start < 0.0 || end > 1.0 || start > end {
109 return Err(GuardError::InvalidQuantity);
110 }
111 Ok(Self {
112 element,
113 horizontal_gap_metres,
114 top_offset_metres,
115 edge_interval,
116 landing_width_metres,
117 curb_top_offset_metres,
118 })
119 }
120
121 pub fn element(&self) -> &ObjectId {
122 &self.element
123 }
124 pub fn horizontal_gap_metres(&self) -> f64 {
125 self.horizontal_gap_metres
126 }
127 pub fn top_offset_metres(&self) -> f64 {
128 self.top_offset_metres
129 }
130 pub fn edge_interval(&self) -> [f64; 2] {
131 self.edge_interval
132 }
133 pub fn landing_width_metres(&self) -> f64 {
134 self.landing_width_metres
135 }
136 pub fn curb_top_offset_metres(&self) -> Option<f64> {
137 self.curb_top_offset_metres
138 }
139}
140
141#[derive(Clone, Debug, PartialEq)]
143pub struct ClimbableCandidate {
144 element: ObjectId,
145 barrier: ObjectId,
146 distance_to_barrier_metres: f64,
147 top_offset_metres: f64,
148 minimum_side_length_metres: f64,
149}
150
151impl ClimbableCandidate {
152 pub fn try_new(
153 element: ObjectId,
154 barrier: ObjectId,
155 distance_to_barrier_metres: f64,
156 top_offset_metres: f64,
157 minimum_side_length_metres: f64,
158 ) -> Result<Self, GuardError> {
159 if !distance_to_barrier_metres.is_finite()
160 || !top_offset_metres.is_finite()
161 || !minimum_side_length_metres.is_finite()
162 || distance_to_barrier_metres < 0.0
163 || minimum_side_length_metres < 0.0
164 {
165 return Err(GuardError::InvalidQuantity);
166 }
167 Ok(Self {
168 element,
169 barrier,
170 distance_to_barrier_metres,
171 top_offset_metres,
172 minimum_side_length_metres,
173 })
174 }
175 pub fn element(&self) -> &ObjectId {
176 &self.element
177 }
178 pub fn barrier(&self) -> &ObjectId {
179 &self.barrier
180 }
181 pub fn distance_to_barrier_metres(&self) -> f64 {
182 self.distance_to_barrier_metres
183 }
184 pub fn top_offset_metres(&self) -> f64 {
185 self.top_offset_metres
186 }
187 pub fn minimum_side_length_metres(&self) -> f64 {
188 self.minimum_side_length_metres
189 }
190}
191
192#[derive(Clone, Debug, PartialEq)]
194pub struct GuardEdge {
195 surface: ObjectId,
196 barriers: Vec<GuardCandidate>,
197 landings: Vec<GuardCandidate>,
198 climbables: Vec<ClimbableCandidate>,
199}
200
201impl GuardEdge {
202 pub fn new(
203 surface: ObjectId,
204 barriers: Vec<GuardCandidate>,
205 landings: Vec<GuardCandidate>,
206 climbables: Vec<ClimbableCandidate>,
207 ) -> Self {
208 Self {
209 surface,
210 barriers,
211 landings,
212 climbables,
213 }
214 }
215 pub fn surface(&self) -> &ObjectId {
216 &self.surface
217 }
218 pub fn barriers(&self) -> &[GuardCandidate] {
219 &self.barriers
220 }
221 pub fn landings(&self) -> &[GuardCandidate] {
222 &self.landings
223 }
224 pub fn climbables(&self) -> &[ClimbableCandidate] {
225 &self.climbables
226 }
227
228 pub fn covered_fraction(candidates: &[GuardCandidate], maximum_gap_metres: f64) -> f64 {
235 let mut intervals: Vec<[f64; 2]> = candidates
236 .iter()
237 .filter(|candidate| candidate.horizontal_gap_metres() <= maximum_gap_metres)
238 .map(GuardCandidate::edge_interval)
239 .collect();
240 intervals.sort_by(|a, b| a[0].total_cmp(&b[0]));
241 let mut covered = 0.0;
242 let mut cursor = f64::NEG_INFINITY;
243 for [start, end] in intervals {
244 let from = start.max(cursor);
245 if end > from {
246 covered += end - from;
247 cursor = end;
248 }
249 }
250 covered
251 }
252}
253
254#[derive(Clone, Debug, PartialEq)]
256pub struct GuardEvidence {
257 edges: Vec<GuardEdge>,
258 evaluated_surfaces: usize,
259 evidence: Evidence,
260}
261
262impl GuardEvidence {
263 pub fn try_new(
264 edges: Vec<GuardEdge>,
265 evaluated_surfaces: usize,
266 evidence: Evidence,
267 ) -> Result<Self, GuardError> {
268 if !reviewable_exact_evidence(&evidence) {
269 return Err(GuardError::InexactEvidence);
270 }
271 Ok(Self {
272 edges,
273 evaluated_surfaces,
274 evidence,
275 })
276 }
277 pub fn edges(&self) -> &[GuardEdge] {
278 &self.edges
279 }
280 pub fn evaluated_surfaces(&self) -> usize {
282 self.evaluated_surfaces
283 }
284 pub fn evidence(&self) -> &Evidence {
285 &self.evidence
286 }
287}
288
289pub trait GuardService: Send + Sync + 'static {
293 fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError>;
294}
295
296#[derive(Clone)]
298pub struct GuardServiceHandle(Arc<dyn GuardService>);
299
300impl GuardServiceHandle {
301 pub fn new(service: Arc<dyn GuardService>) -> Self {
302 Self(service)
303 }
304 pub fn measure_guard_edges(&self, search: GuardSearch) -> Result<GuardEvidence, GuardError> {
305 self.0.measure_guard_edges(search)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use axioval_ir::SourceId;
313
314 fn oid(local: &str) -> ObjectId {
315 ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
316 }
317 fn candidate(gap: f64, interval: [f64; 2]) -> GuardCandidate {
318 GuardCandidate::try_new(oid("rail"), gap, 1.1, interval, 0.0, None).unwrap()
319 }
320
321 #[test]
324 fn overlapping_candidates_are_unioned_not_summed() {
325 let covered = GuardEdge::covered_fraction(
326 &[candidate(0.0, [0.0, 0.5]), candidate(0.0, [0.25, 0.5])],
327 0.1,
328 );
329 assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
330 }
331
332 #[test]
333 fn candidates_beyond_the_gap_do_not_count_as_coverage() {
334 let covered = GuardEdge::covered_fraction(&[candidate(0.9, [0.0, 1.0])], 0.1);
335 assert!(covered.abs() < 1.0e-9, "{covered}");
336 }
337
338 #[test]
339 fn disjoint_candidates_accumulate() {
340 let covered = GuardEdge::covered_fraction(
341 &[candidate(0.0, [0.0, 0.25]), candidate(0.0, [0.75, 1.0])],
342 0.1,
343 );
344 assert!((covered - 0.5).abs() < 1.0e-9, "{covered}");
345 }
346
347 #[test]
348 fn malformed_intervals_are_refused() {
349 for interval in [[0.5, 0.25], [-0.1, 0.5], [0.0, 1.5], [f64::NAN, 1.0]] {
350 assert_eq!(
351 GuardCandidate::try_new(oid("r"), 0.0, 1.0, interval, 0.0, None),
352 Err(GuardError::InvalidQuantity)
353 );
354 }
355 }
356
357 #[test]
358 fn non_positive_search_radii_are_refused() {
359 assert_eq!(
360 GuardSearch::try_new(0.0, 0.1),
361 Err(GuardError::InvalidSearch)
362 );
363 assert_eq!(
364 GuardSearch::try_new(1.0, 0.0),
365 Err(GuardError::InvalidSearch)
366 );
367 assert!(GuardSearch::try_new(1.0, 0.1).is_ok());
368 }
369}