trailgen_core/io/
route_file.rs1use 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#[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}