Skip to main content

vct_finish_layer_count_2026/
lib.rs

1//! VCT Finish Layer Count 2026 — Rust Engine
2//!
3//! Burnishing response, repairability, and COF/slip compliance calculator
4//! for commercial VCT floor maintenance programs.
5//!
6//! Reference: <https://www.binx.ca/guides/vct-finish-layer-count-optimization-2026.pdf>
7
8use std::collections::HashMap;
9
10/// Finish formulation type
11#[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/// Floor traffic level
29#[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/// Zone type for COF compliance threshold selection
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ZoneType {
51    DryGeneral,
52    WetGeneral,
53    AccessibleRoute,
54    HealthcareWet,
55}
56
57/// Burnishing response assessment
58#[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/// Repairability assessment
68#[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/// Program recommendation
78#[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/// Full coat count assessment result
87#[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/// COF compliance result
98#[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    // acrylic_zinc
116    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    // acrylic_no_metal
123    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    // high_solids
129    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
193/// Assess a VCT finish coat count program.
194pub 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
245/// Assess COF compliance for a finished VCT floor.
246pub 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}