Skip to main content

axioval_engine/
coverage.rs

1//! Effective coverage: how much of a footprint the union of several sources'
2//! effect areas covers.
3//!
4//! ADR 0004: this seam measures the covered area. Which sources count, how
5//! far they reach and what share must be covered are a rule's judgement.
6//!
7//! Every source has an **effect area** in plan, of one [`EffectReach`] for
8//! the whole request: its footprint grown by the range, the points within
9//! the range of travel from its centre, or the points it sees from its
10//! centre within the range. The covered area is the union of the effect
11//! areas clipped to the subject's footprint, reported as an interval: its
12//! lower bound comes from inner bounds of the effects of *certain* sources
13//! only, its upper bound from outer bounds of every effect. An effect that
14//! could not be measured leaves the upper bound at the whole footprint.
15//!
16//! Travel and sight stay within the subject's **free region**: its footprint
17//! less the footprints of the blockers. Blockers the request marks uncertain
18//! narrow only the inner bounds, since they may be absent.
19//!
20//! A request may **continue effects into connected spaces**: the free region
21//! then also holds the footprints of the connected spaces and of the doors
22//! and openings (passages) joining them, so a sprinkler in the next room
23//! travels or sees through an open doorway into the subject. The covered
24//! area is still clipped to the subject's footprint. Connections the
25//! request marks uncertain widen only the outer bounds, since they may be
26//! absent; a connection that cannot be measured leaves the upper bound at
27//! the whole footprint. A grown effect ignores walls already, so it takes
28//! no connections.
29
30use axioval_ir::{Evidence, ObjectId};
31
32use crate::{PlanArea, PlanAreaError};
33
34/// How far one source's effect reaches.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum EffectReach {
37    /// The source's footprint grown by the range in every plan direction.
38    Grown,
39    /// The points of the free region whose shortest path within it from
40    /// the source's footprint centre is no longer than the range.
41    Travel,
42    /// The points of the free region the source's footprint centre sees
43    /// within it, no farther than the range.
44    Visible,
45}
46
47impl EffectReach {
48    /// The reach's name, as a rule states it.
49    #[must_use]
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Grown => "grown",
53            Self::Travel => "travel",
54            Self::Visible => "visible",
55        }
56    }
57}
58
59/// An object taking part in a coverage request, and whether it surely does.
60#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct Participant {
62    object: ObjectId,
63    certain: bool,
64}
65
66impl Participant {
67    /// `object`, which surely takes part when `certain` and may otherwise.
68    #[must_use]
69    pub fn new(object: ObjectId, certain: bool) -> Self {
70        Self { object, certain }
71    }
72
73    /// The participating object.
74    #[must_use]
75    pub fn object(&self) -> &ObjectId {
76        &self.object
77    }
78
79    /// Whether it surely takes part.
80    #[must_use]
81    pub fn is_certain(&self) -> bool {
82        self.certain
83    }
84}
85
86/// A question about one subject: how much of its footprint the sources'
87/// effect areas cover.
88#[derive(Clone, Debug, PartialEq)]
89pub struct CoverageRequest {
90    subject: ObjectId,
91    reach: EffectReach,
92    range: f64,
93    sources: Vec<Participant>,
94    blockers: Vec<Participant>,
95    connected: Vec<Participant>,
96    passages: Vec<Participant>,
97}
98
99impl CoverageRequest {
100    /// Coverage of `subject` by `sources`, each reaching `range_metres` as
101    /// `reach` says, travel and sight avoiding `blockers`.
102    ///
103    /// Sources and blockers are sorted by object; an object listed twice
104    /// keeps its certain listing.
105    ///
106    /// # Errors
107    ///
108    /// [`PlanAreaError::Unavailable`] for a range that is negative or not
109    /// finite, or an object that is both subject, source or blocker.
110    pub fn try_new(
111        subject: ObjectId,
112        reach: EffectReach,
113        range_metres: f64,
114        sources: Vec<Participant>,
115        blockers: Vec<Participant>,
116    ) -> Result<Self, PlanAreaError> {
117        if !range_metres.is_finite() || range_metres < 0.0 {
118            return Err(PlanAreaError::Unavailable(format!(
119                "a range of {range_metres} m is not a non-negative length"
120            )));
121        }
122        let sources = merged(sources);
123        let blockers = merged(blockers);
124        let named = |list: &[Participant], object: &ObjectId| {
125            list.iter()
126                .any(|participant| participant.object() == object)
127        };
128        if named(&sources, &subject) || named(&blockers, &subject) {
129            return Err(PlanAreaError::Unavailable(format!(
130                "{subject} cannot cover or block its own footprint"
131            )));
132        }
133        if let Some(both) = sources
134            .iter()
135            .find(|source| named(&blockers, source.object()))
136        {
137            return Err(PlanAreaError::Unavailable(format!(
138                "{} is both a source and a blocker",
139                both.object()
140            )));
141        }
142        Ok(Self {
143            subject,
144            reach,
145            range: range_metres,
146            sources,
147            blockers,
148            connected: Vec::new(),
149            passages: Vec::new(),
150        })
151    }
152
153    /// Continues travel and sight into the `connected` spaces through the
154    /// `passages` (doors and openings) joining them to the subject: the free
155    /// region becomes the union of the subject's, the connected spaces' and
156    /// the passages' footprints, less the blockers. A service that cannot
157    /// continue effects refuses such a request; it never ignores the
158    /// connections.
159    ///
160    /// Both lists are sorted by object; an object listed twice keeps its
161    /// certain listing.
162    ///
163    /// # Errors
164    ///
165    /// [`PlanAreaError::Unavailable`] for a grown reach, which ignores walls
166    /// and has nothing to continue through, or a connected space or passage
167    /// that is also the subject, a source, a blocker, or both.
168    pub fn with_connections(
169        mut self,
170        connected: Vec<Participant>,
171        passages: Vec<Participant>,
172    ) -> Result<Self, PlanAreaError> {
173        if self.reach == EffectReach::Grown && !(connected.is_empty() && passages.is_empty()) {
174            return Err(PlanAreaError::Unavailable(
175                "a grown effect ignores walls, so it continues through no connection".into(),
176            ));
177        }
178        let connected = merged(connected);
179        let passages = merged(passages);
180        let taken = |object: &ObjectId| {
181            *object == self.subject
182                || self
183                    .sources
184                    .iter()
185                    .chain(&self.blockers)
186                    .any(|participant| participant.object() == object)
187        };
188        if let Some(clash) = connected
189            .iter()
190            .chain(&passages)
191            .find(|participant| taken(participant.object()))
192        {
193            return Err(PlanAreaError::Unavailable(format!(
194                "{} cannot be a connection and the subject, a source or a blocker",
195                clash.object()
196            )));
197        }
198        if let Some(both) = connected.iter().find(|space| {
199            passages
200                .iter()
201                .any(|passage| passage.object() == space.object())
202        }) {
203            return Err(PlanAreaError::Unavailable(format!(
204                "{} is both a connected space and a passage",
205                both.object()
206            )));
207        }
208        self.connected = connected;
209        self.passages = passages;
210        Ok(self)
211    }
212
213    /// The object whose footprint is covered.
214    #[must_use]
215    pub fn subject(&self) -> &ObjectId {
216        &self.subject
217    }
218
219    /// How every source's effect reaches.
220    #[must_use]
221    pub fn reach(&self) -> EffectReach {
222        self.reach
223    }
224
225    /// How far, in metres.
226    #[must_use]
227    pub fn range_metres(&self) -> f64 {
228        self.range
229    }
230
231    /// The sources, sorted by object.
232    #[must_use]
233    pub fn sources(&self) -> &[Participant] {
234        &self.sources
235    }
236
237    /// The blockers travel and sight avoid, sorted by object.
238    #[must_use]
239    pub fn blockers(&self) -> &[Participant] {
240        &self.blockers
241    }
242
243    /// The spaces effects continue into, sorted by object; empty unless
244    /// [`Self::with_connections`] added them.
245    #[must_use]
246    pub fn connected(&self) -> &[Participant] {
247        &self.connected
248    }
249
250    /// The doors and openings joining the connected spaces to the subject,
251    /// sorted by object.
252    #[must_use]
253    pub fn passages(&self) -> &[Participant] {
254        &self.passages
255    }
256}
257
258/// Sorted by object, one entry per object, certain where any listing is.
259fn merged(mut list: Vec<Participant>) -> Vec<Participant> {
260    list.sort_by(|a, b| a.object.cmp(&b.object).then(b.certain.cmp(&a.certain)));
261    list.dedup_by(|later, kept| later.object == kept.object);
262    list
263}
264
265/// Whether one source's effect area meets the subject's footprint.
266#[derive(Clone, Debug, PartialEq, Eq)]
267pub enum EffectMeets {
268    /// Its inner bound covers part of the footprint.
269    Surely,
270    /// Its outer bound reaches the footprint, its inner bound does not.
271    Possibly,
272    /// Its outer bound misses the footprint.
273    No,
274    /// It could not be measured, for the reason given.
275    Unmeasured(String),
276}
277
278/// The answer to a [`CoverageRequest`].
279#[derive(Clone, Debug, PartialEq)]
280pub struct CoverageEvidence {
281    subject: ObjectId,
282    footprint: PlanArea,
283    covered: PlanArea,
284    effects: Vec<(ObjectId, EffectMeets)>,
285}
286
287impl CoverageEvidence {
288    /// The subject's `footprint`, the `covered` part of it and, per source
289    /// in request order, whether its effect meets the footprint.
290    ///
291    /// # Errors
292    ///
293    /// [`PlanAreaError::InvalidMeasurement`] for a covered area that may
294    /// exceed the footprint.
295    pub fn try_new(
296        subject: ObjectId,
297        footprint: PlanArea,
298        covered: PlanArea,
299        effects: Vec<(ObjectId, EffectMeets)>,
300    ) -> Result<Self, PlanAreaError> {
301        if covered.lower_square_metres() > footprint.upper_square_metres() {
302            return Err(PlanAreaError::InvalidMeasurement);
303        }
304        Ok(Self {
305            subject,
306            footprint,
307            covered,
308            effects,
309        })
310    }
311
312    /// The object whose footprint was covered.
313    #[must_use]
314    pub fn subject(&self) -> &ObjectId {
315        &self.subject
316    }
317
318    /// The subject's footprint area.
319    #[must_use]
320    pub fn footprint(&self) -> &PlanArea {
321        &self.footprint
322    }
323
324    /// The covered part of the footprint.
325    #[must_use]
326    pub fn covered(&self) -> &PlanArea {
327        &self.covered
328    }
329
330    /// Per source, whether its effect meets the footprint.
331    #[must_use]
332    pub fn effects(&self) -> &[(ObjectId, EffectMeets)] {
333        &self.effects
334    }
335
336    /// Reviewable provenance of the footprint and the covered area.
337    #[must_use]
338    pub fn evidence(&self) -> [&Evidence; 2] {
339        [self.footprint.evidence(), self.covered.evidence()]
340    }
341}
342
343/// Checks an answer against its request: the same subject, one entry per
344/// requested source in order, and a covered area no larger than the
345/// footprint could be.
346pub(crate) fn check_answer(
347    request: &CoverageRequest,
348    answer: &CoverageEvidence,
349) -> Result<(), PlanAreaError> {
350    if answer.subject() != request.subject() {
351        return Err(PlanAreaError::Unavailable(format!(
352            "coverage of {} was returned for {}",
353            answer.subject(),
354            request.subject()
355        )));
356    }
357    let answered = answer.effects().iter().map(|(object, _)| object);
358    let asked = request.sources().iter().map(Participant::object);
359    if !answered.eq(asked) {
360        return Err(PlanAreaError::Unavailable(
361            "the coverage answer does not list the requested sources".into(),
362        ));
363    }
364    Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use axioval_ir::SourceId;
371
372    fn id(local: &str) -> ObjectId {
373        ObjectId::new(SourceId::new("cad", "m").unwrap(), local).unwrap()
374    }
375
376    fn area(value: f64) -> PlanArea {
377        PlanArea::try_new(
378            value,
379            value,
380            Evidence::exact(SourceId::new("cad", "m").unwrap(), "area"),
381        )
382        .unwrap()
383    }
384
385    #[test]
386    fn a_request_merges_listings_and_refuses_overlaps_and_bad_ranges() {
387        let source = |local, certain| Participant::new(id(local), certain);
388        let request = CoverageRequest::try_new(
389            id("s"),
390            EffectReach::Grown,
391            1.0,
392            vec![source("b", false), source("a", false), source("b", true)],
393            vec![],
394        )
395        .unwrap();
396        assert_eq!(request.sources(), [source("a", false), source("b", true)]);
397        for range in [-1.0, f64::NAN] {
398            assert!(
399                CoverageRequest::try_new(id("s"), EffectReach::Grown, range, vec![], vec![])
400                    .is_err()
401            );
402        }
403        assert!(
404            CoverageRequest::try_new(
405                id("s"),
406                EffectReach::Travel,
407                1.0,
408                vec![source("s", true)],
409                vec![]
410            )
411            .is_err()
412        );
413        assert!(
414            CoverageRequest::try_new(
415                id("s"),
416                EffectReach::Travel,
417                1.0,
418                vec![source("a", true)],
419                vec![source("a", false)]
420            )
421            .is_err()
422        );
423    }
424
425    #[test]
426    fn connections_are_merged_and_refused_where_they_overlap() {
427        let one = |local, certain| Participant::new(id(local), certain);
428        let request = || {
429            CoverageRequest::try_new(
430                id("s"),
431                EffectReach::Travel,
432                5.0,
433                vec![one("a", true)],
434                vec![one("w", true)],
435            )
436            .unwrap()
437        };
438        let joined = request()
439            .with_connections(vec![one("n", false), one("n", true)], vec![one("d", false)])
440            .unwrap();
441        assert_eq!(joined.connected(), [one("n", true)]);
442        assert_eq!(joined.passages(), [one("d", false)]);
443        assert!(request().connected().is_empty() && request().passages().is_empty());
444        for (connected, passages) in [
445            (vec![one("s", true)], vec![]),
446            (vec![one("a", true)], vec![]),
447            (vec![], vec![one("w", false)]),
448            (vec![one("n", true)], vec![one("n", true)]),
449        ] {
450            assert!(request().with_connections(connected, passages).is_err());
451        }
452        let grown =
453            CoverageRequest::try_new(id("s"), EffectReach::Grown, 1.0, vec![], vec![]).unwrap();
454        assert!(
455            grown
456                .clone()
457                .with_connections(vec![one("n", true)], vec![])
458                .is_err()
459        );
460        assert!(grown.with_connections(vec![], vec![]).is_ok());
461    }
462
463    #[test]
464    fn an_answer_must_fit_its_request() {
465        let request = CoverageRequest::try_new(
466            id("s"),
467            EffectReach::Grown,
468            1.0,
469            vec![Participant::new(id("a"), true)],
470            vec![],
471        )
472        .unwrap();
473        assert!(CoverageEvidence::try_new(id("s"), area(1.0), area(2.0), vec![]).is_err());
474        let wrong = CoverageEvidence::try_new(id("s"), area(1.0), area(0.5), vec![]).unwrap();
475        assert!(check_answer(&request, &wrong).is_err());
476        let right = CoverageEvidence::try_new(
477            id("s"),
478            area(1.0),
479            area(0.5),
480            vec![(id("a"), EffectMeets::Surely)],
481        )
482        .unwrap();
483        assert!(check_answer(&request, &right).is_ok());
484    }
485}