Skip to main content

trailgen_core/
constraints.rs

1use crate::model::Terrain;
2use crate::route::{RouteMetrics, RouteShape};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6pub const DEFAULT_MIN_DISTANCE_M: f64 = 5_000.0;
7pub const DEFAULT_MAX_DISTANCE_M: f64 = 12_000.0;
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct LoopConstraints {
11    #[serde(default = "default_min_distance_m")]
12    pub min_distance_m: f64,
13    #[serde(default = "default_max_distance_m")]
14    pub max_distance_m: f64,
15    #[serde(default)]
16    pub min_lower_limb_load_km: f64,
17    #[serde(default = "default_unbounded")]
18    pub max_lower_limb_load_km: f64,
19    /// Preferred flat-gravel joint-work-equivalent load inside the lawful
20    /// load window. This guides ranking; it is not another constraint.
21    #[serde(default)]
22    pub target_lower_limb_load_km: Option<f64>,
23    #[serde(default)]
24    pub min_moving_time_s: f64,
25    #[serde(default = "default_unbounded")]
26    pub max_moving_time_s: f64,
27    #[serde(default)]
28    pub min_ascent_m: f64,
29    #[serde(default = "default_max_elevation_m")]
30    pub max_ascent_m: f64,
31    #[serde(default)]
32    pub min_descent_m: f64,
33    #[serde(default = "default_max_elevation_m")]
34    pub max_descent_m: f64,
35    #[serde(default = "default_max_road_fraction")]
36    pub max_road_fraction: f64,
37    #[serde(default = "default_max_low_confidence_fraction")]
38    pub max_low_confidence_fraction: f64,
39    #[serde(default)]
40    pub max_restricted_access_fraction: f64,
41    #[serde(default)]
42    pub max_repeated_edge_fraction: f64,
43    #[serde(default = "default_allowed_shapes")]
44    pub allowed_shapes: Vec<RouteShape>,
45    #[serde(default)]
46    pub forbidden_terrain: Vec<Terrain>,
47    #[serde(default)]
48    pub min_terrain_fraction: BTreeMap<Terrain, f64>,
49    #[serde(default)]
50    pub max_terrain_fraction: BTreeMap<Terrain, f64>,
51}
52
53impl Default for LoopConstraints {
54    fn default() -> Self {
55        Self {
56            min_distance_m: DEFAULT_MIN_DISTANCE_M,
57            max_distance_m: DEFAULT_MAX_DISTANCE_M,
58            min_lower_limb_load_km: 0.0,
59            max_lower_limb_load_km: f64::MAX,
60            target_lower_limb_load_km: None,
61            min_moving_time_s: 0.0,
62            max_moving_time_s: f64::MAX,
63            min_ascent_m: 0.0,
64            max_ascent_m: 3_000.0,
65            min_descent_m: 0.0,
66            max_descent_m: 3_000.0,
67            max_road_fraction: 1.0,
68            max_low_confidence_fraction: 0.20,
69            max_restricted_access_fraction: 0.0,
70            max_repeated_edge_fraction: 0.0,
71            allowed_shapes: default_allowed_shapes(),
72            forbidden_terrain: Vec::new(),
73            min_terrain_fraction: BTreeMap::new(),
74            max_terrain_fraction: BTreeMap::new(),
75        }
76    }
77}
78
79impl LoopConstraints {
80    #[must_use]
81    pub fn judge(&self, metrics: &RouteMetrics) -> ConstraintVerdict {
82        let mut violations = self
83            .core_checks(metrics)
84            .into_iter()
85            .filter_map(BoundCheck::violation)
86            .collect();
87        self.append_shape_violations(metrics, &mut violations);
88        self.append_terrain_violations(metrics, &mut violations);
89        ConstraintVerdict {
90            satisfied: violations.is_empty(),
91            violations,
92            audit: self.audit(metrics),
93            penalty: self.penalty(metrics),
94        }
95    }
96
97    #[must_use]
98    pub fn audit(&self, metrics: &RouteMetrics) -> Vec<ConstraintAudit> {
99        let mut audit = self.core_audit(metrics);
100        self.append_terrain_audit(metrics, &mut audit);
101        audit
102    }
103
104    fn core_audit(&self, metrics: &RouteMetrics) -> Vec<ConstraintAudit> {
105        self.core_checks(metrics)
106            .into_iter()
107            .map(BoundCheck::audit)
108            .chain(std::iter::once(shape_check(
109                metrics.shape,
110                &self.allowed_shapes,
111            )))
112            .collect()
113    }
114
115    fn core_checks(&self, m: &RouteMetrics) -> [BoundCheck<'_>; 14] {
116        [
117            BoundCheck::min(
118                "distance",
119                m.distance_m / 1_000.0,
120                self.min_distance_m / 1_000.0,
121                "km",
122                2,
123            ),
124            BoundCheck::max(
125                "distance",
126                m.distance_m / 1_000.0,
127                self.max_distance_m / 1_000.0,
128                "km",
129                2,
130            ),
131            BoundCheck::min(
132                "lower-limb load",
133                m.lower_limb_load_km,
134                self.min_lower_limb_load_km,
135                "FGJW km",
136                2,
137            ),
138            BoundCheck::max(
139                "lower-limb load",
140                m.lower_limb_load_km,
141                self.max_lower_limb_load_km,
142                "FGJW km",
143                2,
144            ),
145            BoundCheck::min(
146                "moving time",
147                m.moving_time_s / 3_600.0,
148                self.min_moving_time_s / 3_600.0,
149                "h",
150                2,
151            ),
152            BoundCheck::max(
153                "moving time",
154                m.moving_time_s / 3_600.0,
155                self.max_moving_time_s / 3_600.0,
156                "h",
157                2,
158            ),
159            BoundCheck::min("ascent", m.ascent_m, self.min_ascent_m, "m", 0),
160            BoundCheck::max("ascent", m.ascent_m, self.max_ascent_m, "m", 0),
161            BoundCheck::min("descent", m.descent_m, self.min_descent_m, "m", 0),
162            BoundCheck::max("descent", m.descent_m, self.max_descent_m, "m", 0),
163            BoundCheck::max_named(
164                "road exposure",
165                "road fraction",
166                m.road_fraction * 100.0,
167                self.max_road_fraction * 100.0,
168                "%",
169                1,
170            ),
171            BoundCheck::max_named(
172                "low-confidence exposure",
173                "low-confidence fraction",
174                m.low_confidence_fraction * 100.0,
175                self.max_low_confidence_fraction * 100.0,
176                "%",
177                1,
178            ),
179            BoundCheck::max_named(
180                "restricted-access exposure",
181                "restricted-access fraction",
182                m.restricted_access_fraction * 100.0,
183                self.max_restricted_access_fraction * 100.0,
184                "%",
185                1,
186            ),
187            BoundCheck::max_named(
188                "repeated-edge exposure",
189                "repeated-edge fraction",
190                m.repeated_edge_fraction * 100.0,
191                self.max_repeated_edge_fraction * 100.0,
192                "%",
193                1,
194            ),
195        ]
196    }
197
198    fn append_terrain_audit(&self, metrics: &RouteMetrics, audit: &mut Vec<ConstraintAudit>) {
199        let terrain_fraction = metrics.terrain_percentages();
200        audit.extend(self.forbidden_terrain.iter().map(|terrain| {
201            let fraction = terrain_fraction.get(terrain).copied().unwrap_or_default() * 100.0;
202            ConstraintAudit {
203                metric: format!("forbidden terrain {terrain:?}"),
204                measured: percent(fraction, 1),
205                requirement: "must be absent".to_owned(),
206                margin: if fraction <= f64::EPSILON {
207                    "absent".to_owned()
208                } else {
209                    format!("violates by {}", percent(fraction, 1))
210                },
211                satisfied: fraction <= f64::EPSILON,
212            }
213        }));
214        audit.extend(self.min_terrain_fraction.iter().map(|(terrain, minimum)| {
215            min_check(
216                &format!("minimum terrain {terrain:?}"),
217                terrain_fraction.get(terrain).copied().unwrap_or_default() * 100.0,
218                minimum * 100.0,
219                "%",
220                1,
221            )
222        }));
223        audit.extend(self.max_terrain_fraction.iter().map(|(terrain, maximum)| {
224            max_check(
225                &format!("maximum terrain {terrain:?}"),
226                terrain_fraction.get(terrain).copied().unwrap_or_default() * 100.0,
227                maximum * 100.0,
228                "%",
229                1,
230            )
231        }));
232    }
233
234    fn append_shape_violations(&self, metrics: &RouteMetrics, violations: &mut Vec<String>) {
235        push_violation(
236            violations,
237            !self.allows_shape(metrics.shape),
238            format!(
239                "route shape {:?} is not in allowed shapes {:?}",
240                metrics.shape, self.allowed_shapes
241            ),
242        );
243    }
244
245    fn append_terrain_violations(&self, metrics: &RouteMetrics, violations: &mut Vec<String>) {
246        let terrain_fraction = metrics.terrain_percentages();
247        for terrain in &self.forbidden_terrain {
248            let fraction = terrain_fraction.get(terrain).copied().unwrap_or_default();
249            push_violation(
250                violations,
251                fraction > 0.0,
252                format!(
253                    "forbidden terrain {terrain:?} present at {:.1}%",
254                    fraction * 100.0
255                ),
256            );
257        }
258        for (terrain, minimum) in &self.min_terrain_fraction {
259            let fraction = terrain_fraction.get(terrain).copied().unwrap_or_default();
260            push_violation(
261                violations,
262                fraction < *minimum,
263                format!(
264                    "terrain {terrain:?} fraction {:.1}% below minimum {:.1}%",
265                    fraction * 100.0,
266                    minimum * 100.0
267                ),
268            );
269        }
270        for (terrain, maximum) in &self.max_terrain_fraction {
271            let fraction = terrain_fraction.get(terrain).copied().unwrap_or_default();
272            push_violation(
273                violations,
274                fraction > *maximum,
275                format!(
276                    "terrain {terrain:?} fraction {:.1}% above maximum {:.1}%",
277                    fraction * 100.0,
278                    maximum * 100.0
279                ),
280            );
281        }
282    }
283
284    #[must_use]
285    pub fn allows_shape(&self, shape: RouteShape) -> bool {
286        self.allowed_shapes.contains(&shape)
287    }
288
289    #[must_use]
290    pub fn penalty(&self, m: &RouteMetrics) -> f64 {
291        let core = self
292            .core_checks(m)
293            .into_iter()
294            .map(BoundCheck::normalized_breach)
295            .sum::<f64>();
296        let shape = if self.allows_shape(m.shape) { 0.0 } else { 4.0 };
297        let terrain_fraction = m.terrain_percentages();
298        let forbidden = self
299            .forbidden_terrain
300            .iter()
301            .map(|terrain| terrain_fraction.get(terrain).copied().unwrap_or_default() * 4.0)
302            .sum::<f64>();
303        let terrain_under = self
304            .min_terrain_fraction
305            .iter()
306            .map(|(terrain, minimum)| {
307                ((minimum - terrain_fraction.get(terrain).copied().unwrap_or_default())
308                    / minimum.max(0.01))
309                .max(0.0)
310            })
311            .sum::<f64>();
312        let terrain_over = self
313            .max_terrain_fraction
314            .iter()
315            .map(|(terrain, maximum)| {
316                ((terrain_fraction.get(terrain).copied().unwrap_or_default() - maximum)
317                    / maximum.max(0.01))
318                .max(0.0)
319            })
320            .sum::<f64>();
321        100.0 * (core + shape + forbidden + terrain_under + terrain_over)
322    }
323}
324
325#[derive(Clone, Copy)]
326enum BoundKind {
327    Minimum,
328    Maximum,
329}
330
331#[derive(Clone, Copy)]
332struct BoundCheck<'a> {
333    audit_subject: &'a str,
334    violation_subject: &'a str,
335    kind: BoundKind,
336    value: f64,
337    bound: f64,
338    unit: &'static str,
339    decimals: usize,
340}
341
342impl<'a> BoundCheck<'a> {
343    const fn min(
344        subject: &'a str,
345        value: f64,
346        bound: f64,
347        unit: &'static str,
348        decimals: usize,
349    ) -> Self {
350        Self::new(
351            subject,
352            subject,
353            BoundKind::Minimum,
354            value,
355            bound,
356            unit,
357            decimals,
358        )
359    }
360
361    const fn max(
362        subject: &'a str,
363        value: f64,
364        bound: f64,
365        unit: &'static str,
366        decimals: usize,
367    ) -> Self {
368        Self::new(
369            subject,
370            subject,
371            BoundKind::Maximum,
372            value,
373            bound,
374            unit,
375            decimals,
376        )
377    }
378
379    const fn max_named(
380        audit_subject: &'a str,
381        violation_subject: &'a str,
382        value: f64,
383        bound: f64,
384        unit: &'static str,
385        decimals: usize,
386    ) -> Self {
387        Self::new(
388            audit_subject,
389            violation_subject,
390            BoundKind::Maximum,
391            value,
392            bound,
393            unit,
394            decimals,
395        )
396    }
397
398    const fn new(
399        audit_subject: &'a str,
400        violation_subject: &'a str,
401        kind: BoundKind,
402        value: f64,
403        bound: f64,
404        unit: &'static str,
405        decimals: usize,
406    ) -> Self {
407        Self {
408            audit_subject,
409            violation_subject,
410            kind,
411            value,
412            bound,
413            unit,
414            decimals,
415        }
416    }
417
418    fn audit(self) -> ConstraintAudit {
419        let metric = match self.kind {
420            BoundKind::Minimum => format!("minimum {}", self.audit_subject),
421            BoundKind::Maximum => format!("maximum {}", self.audit_subject),
422        };
423        match self.kind {
424            BoundKind::Minimum => {
425                min_check(&metric, self.value, self.bound, self.unit, self.decimals)
426            }
427            BoundKind::Maximum => {
428                max_check(&metric, self.value, self.bound, self.unit, self.decimals)
429            }
430        }
431    }
432
433    fn violation(self) -> Option<String> {
434        (!self.satisfied()).then(|| {
435            let relation = match self.kind {
436                BoundKind::Minimum => "below minimum",
437                BoundKind::Maximum => "above maximum",
438            };
439            format!(
440                "{} {} {relation} {}",
441                self.violation_subject,
442                measure(self.value, self.unit, self.decimals),
443                measure(self.bound, self.unit, self.decimals)
444            )
445        })
446    }
447
448    fn normalized_breach(self) -> f64 {
449        let breach = match self.kind {
450            BoundKind::Minimum => self.bound - self.value,
451            BoundKind::Maximum => self.value - self.bound,
452        };
453        let floor = match self.unit {
454            "km" | "FGJW km" | "h" => 0.001,
455            _ => 1.0,
456        };
457        (breach / self.bound.max(floor)).max(0.0)
458    }
459
460    fn satisfied(self) -> bool {
461        match self.kind {
462            BoundKind::Minimum => self.value >= self.bound,
463            BoundKind::Maximum => self.value <= self.bound,
464        }
465    }
466}
467
468#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
469pub struct ConstraintAudit {
470    pub metric: String,
471    pub measured: String,
472    pub requirement: String,
473    pub margin: String,
474    pub satisfied: bool,
475}
476
477#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
478pub struct ConstraintVerdict {
479    pub satisfied: bool,
480    pub violations: Vec<String>,
481    #[serde(default)]
482    pub audit: Vec<ConstraintAudit>,
483    pub penalty: f64,
484}
485
486fn min_check(
487    metric: &str,
488    value: f64,
489    minimum: f64,
490    unit: &str,
491    decimals: usize,
492) -> ConstraintAudit {
493    ConstraintAudit {
494        metric: metric.to_owned(),
495        measured: measure(value, unit, decimals),
496        requirement: format!("≥ {}", measure(minimum, unit, decimals)),
497        margin: signed_measure(value - minimum, unit, decimals),
498        satisfied: value >= minimum,
499    }
500}
501
502fn max_check(
503    metric: &str,
504    value: f64,
505    maximum: f64,
506    unit: &str,
507    decimals: usize,
508) -> ConstraintAudit {
509    ConstraintAudit {
510        metric: metric.to_owned(),
511        measured: measure(value, unit, decimals),
512        requirement: format!("≤ {}", measure(maximum, unit, decimals)),
513        margin: signed_measure(maximum - value, unit, decimals),
514        satisfied: value <= maximum,
515    }
516}
517
518fn shape_check(shape: RouteShape, allowed: &[RouteShape]) -> ConstraintAudit {
519    let satisfied = allowed.contains(&shape);
520    ConstraintAudit {
521        metric: "allowed shape".to_owned(),
522        measured: format!("{shape:?}"),
523        requirement: format!("one of {allowed:?}"),
524        margin: if satisfied { "allowed" } else { "disallowed" }.to_owned(),
525        satisfied,
526    }
527}
528
529fn measure(value: f64, unit: &str, decimals: usize) -> String {
530    let value = format!("{value:.decimals$}");
531    if unit.is_empty() {
532        value
533    } else if unit == "%" {
534        format!("{value}%")
535    } else {
536        format!("{value} {unit}")
537    }
538}
539
540fn signed_measure(value: f64, unit: &str, decimals: usize) -> String {
541    let value = format!("{value:+.decimals$}");
542    if unit.is_empty() {
543        value
544    } else if unit == "%" {
545        format!("{value}%")
546    } else {
547        format!("{value} {unit}")
548    }
549}
550
551fn percent(value: f64, decimals: usize) -> String {
552    format!("{value:.decimals$}%")
553}
554
555fn push_violation(xs: &mut Vec<String>, bad: bool, msg: String) {
556    if bad {
557        xs.push(msg);
558    }
559}
560
561fn default_allowed_shapes() -> Vec<RouteShape> {
562    vec![RouteShape::Loop]
563}
564
565const fn default_min_distance_m() -> f64 {
566    DEFAULT_MIN_DISTANCE_M
567}
568
569const fn default_max_distance_m() -> f64 {
570    DEFAULT_MAX_DISTANCE_M
571}
572
573const fn default_unbounded() -> f64 {
574    f64::MAX
575}
576
577const fn default_max_elevation_m() -> f64 {
578    3_000.0
579}
580
581const fn default_max_road_fraction() -> f64 {
582    1.0
583}
584
585const fn default_max_low_confidence_fraction() -> f64 {
586    0.20
587}