Skip to main content

trailgen_core/io/
route_file.rs

1use crate::geo::LineString;
2use crate::model::GradeDistribution;
3use crate::route::{Route, RouteMetrics};
4use serde::{Deserialize, Serialize};
5use std::fmt::Write as _;
6
7#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
8pub struct RouteFileMetadata {
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub title: Option<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub description: Option<String>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub recorded_at: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub activity_type: Option<String>,
17}
18
19impl RouteFileMetadata {
20    #[must_use]
21    pub const fn is_empty(&self) -> bool {
22        self.title.is_none()
23            && self.description.is_none()
24            && self.recorded_at.is_none()
25            && self.activity_type.is_none()
26    }
27
28    #[must_use]
29    pub fn title_or<'a>(&'a self, fallback: &'a str) -> &'a str {
30        self.title.as_deref().unwrap_or(fallback)
31    }
32}
33
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct RouteFile {
36    pub line: LineString,
37    #[serde(default, skip_serializing_if = "RouteFileMetadata::is_empty")]
38    pub metadata: RouteFileMetadata,
39}
40
41impl RouteFile {
42    #[must_use]
43    pub const fn new(line: LineString, metadata: RouteFileMetadata) -> Self {
44        Self { line, metadata }
45    }
46}
47
48#[must_use]
49pub fn clean_text(raw: &str) -> Option<String> {
50    let s = raw.trim();
51    (!s.is_empty()).then(|| s.to_owned())
52}
53
54#[must_use]
55pub fn export_summary(route: &Route) -> String {
56    let verdict = if route.verdict.satisfied {
57        "satisfied"
58    } else {
59        "violated"
60    };
61    let mut s = format!(
62        "score {:.2}; pareto rank {}; {}; constraints {verdict}",
63        route.computed_score(),
64        route.pareto_rank,
65        metrics_summary(&route.metrics),
66    );
67    if !route.verdict.violations.is_empty() {
68        let _ = write!(s, "; violations {}", route.verdict.violations.join(" | "));
69    }
70    s
71}
72
73/// A provider-neutral account of the durable measurements carried by a route.
74#[must_use]
75pub fn metrics_summary(metrics: &RouteMetrics) -> String {
76    format!(
77        "shape {:?}; distance {:.2} km; ascent/descent {:.0}/{:.0} m; sustained-steep {:.2} km; grade {}; lower-limb load {:.2} FGJW km; moving time {:.2} h; road {:.1}%; low-confidence {:.1}%; restricted-access {:.1}%; repeated-edge {:.1}%",
78        metrics.shape,
79        metrics.distance_m / 1_000.0,
80        metrics.ascent_m,
81        metrics.descent_m,
82        metrics.sustained_steep_m / 1_000.0,
83        grade_summary(metrics.grade_distribution),
84        metrics.lower_limb_load_km,
85        metrics.moving_time_s / 3_600.0,
86        metrics.road_fraction * 100.0,
87        metrics.low_confidence_fraction * 100.0,
88        metrics.restricted_access_fraction * 100.0,
89        metrics.repeated_edge_fraction * 100.0,
90    )
91}
92
93fn grade_summary(d: GradeDistribution) -> String {
94    let total = d.total_m();
95    if total <= f64::EPSILON {
96        return "none".to_owned();
97    }
98    format!(
99        "flat {:.1}%, rolling {:.1}%, steep {:.1}%, savage {:.1}%",
100        d.flat_m / total * 100.0,
101        d.rolling_m / total * 100.0,
102        d.steep_m / total * 100.0,
103        d.savage_m / total * 100.0,
104    )
105}