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 #[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}