1use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub enum FinishType {
13 AcrylicZinc,
14 AcrylicNoMetal,
15 HighSolids,
16}
17
18impl FinishType {
19 pub fn as_str(&self) -> &'static str {
20 match self {
21 Self::AcrylicZinc => "acrylic_zinc",
22 Self::AcrylicNoMetal => "acrylic_no_metal",
23 Self::HighSolids => "high_solids",
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum TrafficLevel {
31 Low,
32 Medium,
33 High,
34 Extreme,
35}
36
37impl TrafficLevel {
38 pub fn as_str(&self) -> &'static str {
39 match self {
40 Self::Low => "low",
41 Self::Medium => "medium",
42 Self::High => "high",
43 Self::Extreme => "extreme",
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ZoneType {
51 DryGeneral,
52 WetGeneral,
53 AccessibleRoute,
54 HealthcareWet,
55}
56
57#[derive(Debug, Clone)]
59pub struct BurnishResponse {
60 pub gloss_initial: u32,
61 pub gloss_1pass: u32,
62 pub gloss_3pass: u32,
63 pub rating: String,
64 pub heat_risk: String,
65}
66
67#[derive(Debug, Clone)]
69pub struct RepairabilityResult {
70 pub score: u32,
71 pub spot_repair_viable: bool,
72 pub strip_required: bool,
73 pub strip_difficulty: String,
74 pub strip_time_per_1000sqft: u32,
75}
76
77#[derive(Debug, Clone)]
79pub struct Recommendation {
80 pub action: String,
81 pub optimal_range: (u32, u32),
82 pub coats_to_strip_trigger: u32,
83 pub notes: String,
84}
85
86#[derive(Debug, Clone)]
88pub struct CoatCountResult {
89 pub finish_type: String,
90 pub traffic_level: String,
91 pub coat_count: u32,
92 pub burnish_response: BurnishResponse,
93 pub repairability: RepairabilityResult,
94 pub recommendation: Recommendation,
95}
96
97#[derive(Debug, Clone)]
99pub struct CofResult {
100 pub finish_type: String,
101 pub coat_count: u32,
102 pub zone_type: String,
103 pub static_cof_dry: f64,
104 pub static_cof_wet: f64,
105 pub passes_osha_dry: bool,
106 pub passes_nfpa_wet: bool,
107 pub passes_csa_accessible: bool,
108 pub passes_obc_healthcare: bool,
109 pub slip_risk_rating: String,
110 pub action_required: String,
111}
112
113fn burnish_data() -> HashMap<(&'static str, u32), (u32, u32, u32, u32)> {
114 let mut m = HashMap::new();
115 for (c, v) in [
117 (1,(12,18,22,0)),(2,(22,30,34,0)),(3,(34,52,60,0)),(4,(42,64,74,0)),
118 (5,(48,72,82,0)),(6,(52,78,88,0)),(7,(54,80,90,0)),(8,(54,80,89,1)),
119 (9,(53,79,87,1)),(10,(51,75,82,2)),(11,(48,68,74,2)),(12,(44,60,66,2)),
120 (13,(40,54,59,3)),(14,(38,50,55,3)),
121 ] { m.insert(("acrylic_zinc", c), v); }
122 for (c, v) in [
124 (1,(10,15,18,0)),(2,(18,26,30,0)),(3,(28,44,52,0)),(4,(36,56,66,0)),
125 (5,(42,64,74,0)),(6,(44,68,78,0)),(7,(46,70,80,0)),(8,(44,68,76,1)),
126 (9,(42,64,72,1)),(10,(40,60,66,2)),(11,(36,54,58,2)),(12,(32,46,50,3)),
127 ] { m.insert(("acrylic_no_metal", c), v); }
128 for (c, v) in [
130 (1,(18,26,30,0)),(2,(30,48,58,0)),(3,(46,70,82,0)),(4,(54,82,92,0)),
131 (5,(56,84,94,1)),(6,(54,80,88,1)),(7,(50,72,80,2)),(8,(46,66,72,2)),
132 (9,(40,56,62,3)),(10,(34,48,52,3)),
133 ] { m.insert(("high_solids", c), v); }
134 m
135}
136
137fn optimal_ranges() -> HashMap<(&'static str, &'static str), (u32, u32, u32)> {
138 let mut m = HashMap::new();
139 for (k, v) in [
140 (("acrylic_zinc","low"),(3,5,8)),(("acrylic_zinc","medium"),(5,6,9)),
141 (("acrylic_zinc","high"),(6,7,10)),(("acrylic_zinc","extreme"),(7,7,10)),
142 (("acrylic_no_metal","low"),(3,4,8)),(("acrylic_no_metal","medium"),(5,6,9)),
143 (("acrylic_no_metal","high"),(6,7,10)),(("acrylic_no_metal","extreme"),(6,7,10)),
144 (("high_solids","low"),(2,3,6)),(("high_solids","medium"),(3,4,7)),
145 (("high_solids","high"),(4,5,8)),(("high_solids","extreme"),(4,5,8)),
146 ] { m.insert(k, v); }
147 m
148}
149
150fn repair_scores() -> HashMap<u32, (u32, bool, bool, &'static str, u32)> {
151 let mut m = HashMap::new();
152 for (c, v) in [
153 (1,(1,false,true,"easy",18)),(2,(3,false,true,"easy",20)),
154 (3,(6,true,false,"easy",22)),(4,(8,true,false,"easy",24)),
155 (5,(9,true,false,"easy",26)),(6,(9,true,false,"moderate",30)),
156 (7,(8,true,false,"moderate",34)),(8,(6,true,false,"moderate",38)),
157 (9,(4,false,true,"hard",45)),(10,(2,false,true,"hard",52)),
158 (11,(2,false,true,"very_hard",60)),(12,(1,false,true,"very_hard",65)),
159 (13,(1,false,true,"extreme",78)),(14,(1,false,true,"extreme",85)),
160 (15,(1,false,true,"extreme",100)),
161 ] { m.insert(c, v); }
162 m
163}
164
165fn cof_data() -> HashMap<(&'static str, u32), (f64, f64)> {
166 let mut m = HashMap::new();
167 for (c, v) in [
168 (0,(0.62,0.55)),(1,(0.58,0.50)),(2,(0.56,0.47)),(3,(0.55,0.45)),
169 (4,(0.54,0.44)),(5,(0.52,0.42)),(6,(0.51,0.40)),(7,(0.50,0.39)),
170 (8,(0.49,0.37)),(9,(0.48,0.36)),(10,(0.47,0.35)),
171 ] { m.insert(("acrylic_zinc", c), v); }
172 for (c, v) in [
173 (0,(0.63,0.56)),(1,(0.60,0.52)),(2,(0.58,0.50)),(3,(0.57,0.48)),
174 (4,(0.56,0.47)),(5,(0.54,0.45)),(6,(0.53,0.43)),(7,(0.51,0.41)),
175 (8,(0.50,0.39)),(9,(0.49,0.38)),(10,(0.48,0.37)),
176 ] { m.insert(("acrylic_no_metal", c), v); }
177 for (c, v) in [
178 (0,(0.62,0.55)),(1,(0.57,0.49)),(2,(0.55,0.46)),(3,(0.53,0.43)),
179 (4,(0.51,0.41)),(5,(0.50,0.39)),(6,(0.49,0.37)),(7,(0.47,0.35)),
180 (8,(0.46,0.34)),
181 ] { m.insert(("high_solids", c), v); }
182 m
183}
184
185fn clamp_to_nearest(available: &[u32], n: u32) -> u32 {
186 *available.iter().min_by_key(|&&k| (k as i32 - n as i32).unsigned_abs()).unwrap_or(&1)
187}
188
189fn burnish_rating(g3: u32) -> &'static str {
190 if g3 >= 85 { "excellent" } else if g3 >= 70 { "good" } else if g3 >= 55 { "fair" } else { "poor" }
191}
192
193pub fn assess_coat_count(
195 finish_type: FinishType,
196 traffic_level: TrafficLevel,
197 coat_count: u32,
198) -> CoatCountResult {
199 let ft = finish_type.as_str();
200 let tl = traffic_level.as_str();
201 let heat_labels = ["low", "medium", "high", "very_high"];
202 let bd = burnish_data();
203 let available_b: Vec<u32> = bd.keys().filter(|(f,_)| *f == ft).map(|(_,c)| *c).collect();
204 let bkey = clamp_to_nearest(&available_b, coat_count);
205 let (gi, g1, g3, hri) = bd[&(ft, bkey)];
206
207 let rs = repair_scores();
208 let available_r: Vec<u32> = rs.keys().cloned().collect();
209 let rkey = clamp_to_nearest(&available_r, coat_count.min(15).max(1));
210 let (score, spot, strip, diff, stime) = rs[&rkey];
211
212 let or_map = optimal_ranges();
213 let (mn, mx, trigger) = or_map.get(&(ft, tl)).copied().unwrap_or((4, 7, 10));
214 let to_trigger = trigger.saturating_sub(coat_count);
215
216 let (action, notes) = if coat_count < mn {
217 ("add_coats".to_string(), format!("Floor is under-coated. Optimal range for {} traffic ({}) is {}–{} coats. Add {} coat(s) before next burnish.", tl, ft, mn, mx, mn - coat_count))
218 } else if coat_count <= mx {
219 ("maintain".to_string(), format!("Coat count is within optimal range ({}–{}) for {} traffic. Strip cycle triggered at {} coats ({} remaining). Continue scheduled program.", mn, mx, tl, trigger, to_trigger))
220 } else if coat_count < trigger {
221 ("plan_strip".to_string(), format!("Coat count exceeds optimal range. Schedule strip-and-refinish within next maintenance cycle. {} coats remaining before strip trigger.", to_trigger))
222 } else {
223 ("strip_now".to_string(), format!("Strip trigger reached ({} coats). Full strip cycle required. Expect {} strip ({} min/1,000 sq ft).", coat_count, diff, stime))
224 };
225
226 CoatCountResult {
227 finish_type: ft.to_string(),
228 traffic_level: tl.to_string(),
229 coat_count,
230 burnish_response: BurnishResponse {
231 gloss_initial: gi, gloss_1pass: g1, gloss_3pass: g3,
232 rating: burnish_rating(g3).to_string(),
233 heat_risk: heat_labels[hri as usize].to_string(),
234 },
235 repairability: RepairabilityResult {
236 score, spot_repair_viable: spot, strip_required: strip,
237 strip_difficulty: diff.to_string(), strip_time_per_1000sqft: stime,
238 },
239 recommendation: Recommendation {
240 action, optimal_range: (mn, mx), coats_to_strip_trigger: to_trigger, notes,
241 },
242 }
243}
244
245pub fn assess_cof(
247 finish_type: FinishType,
248 coat_count: u32,
249 zone_type: ZoneType,
250) -> CofResult {
251 let ft = finish_type.as_str();
252 let zt_str = match zone_type {
253 ZoneType::DryGeneral => "dry_general",
254 ZoneType::WetGeneral => "wet_general",
255 ZoneType::AccessibleRoute => "accessible_route",
256 ZoneType::HealthcareWet => "healthcare_wet",
257 };
258 let cd = cof_data();
259 let available_c: Vec<u32> = cd.keys().filter(|(f,_)| *f == ft).map(|(_,c)| *c).collect();
260 let ckey = clamp_to_nearest(&available_c, coat_count);
261 let (dry, wet) = cd[&(ft, ckey)];
262
263 let high_zone = matches!(zone_type, ZoneType::AccessibleRoute | ZoneType::HealthcareWet);
264 let wet_thresh = if high_zone { 0.60 } else { 0.50 };
265 let diff = wet - wet_thresh;
266 let slip_risk = if diff >= 0.08 { "low" } else if diff >= 0.02 { "medium" } else if diff >= -0.02 { "medium_high" } else if diff >= -0.08 { "high" } else { "very_high" };
267 let threshold_met = if high_zone { wet >= 0.60 } else { wet >= 0.50 };
268 let action = if threshold_met && dry >= 0.50 { "none" } else if dry >= 0.50 && wet >= 0.44 { "monitor" } else if coat_count >= 9 { "strip_and_retreat" } else { "anti_slip_treatment_required" };
269
270 CofResult {
271 finish_type: ft.to_string(), coat_count, zone_type: zt_str.to_string(),
272 static_cof_dry: (dry * 100.0).round() / 100.0,
273 static_cof_wet: (wet * 100.0).round() / 100.0,
274 passes_osha_dry: dry >= 0.50, passes_nfpa_wet: wet >= 0.50,
275 passes_csa_accessible: dry >= 0.60, passes_obc_healthcare: wet >= 0.60,
276 slip_risk_rating: slip_risk.to_string(), action_required: action.to_string(),
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 #[test]
284 fn medium_traffic_5coats_is_maintain() {
285 let r = assess_coat_count(FinishType::AcrylicZinc, TrafficLevel::Medium, 5);
286 assert_eq!(r.recommendation.action, "maintain");
287 assert_eq!(r.burnish_response.rating, "good");
288 assert_eq!(r.repairability.score, 9);
289 }
290 #[test]
291 fn high_coats_triggers_strip() {
292 let r = assess_coat_count(FinishType::AcrylicZinc, TrafficLevel::Medium, 10);
293 assert_eq!(r.recommendation.action, "strip_now");
294 }
295 #[test]
296 fn wet_zone_fails_nfpa_at_6_coats() {
297 let r = assess_cof(FinishType::AcrylicZinc, 6, ZoneType::WetGeneral);
298 assert!(!r.passes_nfpa_wet);
299 assert_eq!(r.action_required, "anti_slip_treatment_required");
300 }
301}