1use serde::{Deserialize, Serialize};
22
23pub const STEAM_EXPANSION_FACTOR: u32 = 1700;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum ConformancePhase {
34 Frozen,
36 Liquid,
38 Vapor,
40 Unsettled,
42 Decomposed,
44}
45
46impl ConformancePhase {
47 pub fn as_status(self) -> &'static str {
49 match self {
50 Self::Frozen => "BLOCKED",
51 Self::Liquid => "PARTIAL",
52 Self::Vapor => "ADMITTED",
53 Self::Unsettled => "UNKNOWN",
54 Self::Decomposed => "REFUSED",
55 }
56 }
57
58 pub fn expansion_factor(self) -> u32 {
64 match self {
65 Self::Vapor => STEAM_EXPANSION_FACTOR,
66 Self::Liquid => 1,
67 Self::Frozen | Self::Unsettled | Self::Decomposed => 0,
68 }
69 }
70}
71
72impl std::fmt::Display for ConformancePhase {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str(self.as_status())
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PhaseInput {
81 pub andon_active: bool,
83 pub refused: bool,
85 pub unknown: bool,
87 pub conformance: f64,
89 pub boiling_point: f64,
91}
92
93pub fn phase_for(input: &PhaseInput) -> ConformancePhase {
102 if input.andon_active {
103 return ConformancePhase::Frozen;
104 }
105 if input.refused {
106 return ConformancePhase::Decomposed;
107 }
108 if input.unknown {
109 return ConformancePhase::Unsettled;
110 }
111 if input.conformance >= input.boiling_point {
112 ConformancePhase::Vapor
113 } else {
114 ConformancePhase::Liquid
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct PhaseShiftReport {
121 pub phase: String,
123 pub expansion_factor: u32,
125 pub conformance: f64,
127 pub boiling_point: f64,
129 pub crossed_boiling_point: bool,
131 pub summary: String,
133}
134
135pub fn phase_shift_report(input: &PhaseInput) -> PhaseShiftReport {
137 let phase = phase_for(input);
138 let crossed = phase == ConformancePhase::Vapor;
139 let summary = match phase {
140 ConformancePhase::Frozen => {
141 "ANDON active; phase Frozen — status BLOCKED, no observation propagates".to_string()
142 }
143 ConformancePhase::Decomposed => {
144 "explicit refusal; phase Decomposed — status REFUSED, no observation propagates"
145 .to_string()
146 }
147 ConformancePhase::Unsettled => {
148 "measurement undetermined; phase Unsettled — status UNKNOWN, never coerced to PARTIAL \
149 or ADMITTED"
150 .to_string()
151 }
152 ConformancePhase::Liquid => format!(
153 "conformance {:.3} below boiling point {:.3}; phase Liquid — status PARTIAL",
154 input.conformance, input.boiling_point
155 ),
156 ConformancePhase::Vapor => format!(
157 "conformance {:.3} at or above boiling point {:.3}; phase Vapor — status ADMITTED, mesh \
158 expansion {}x",
159 input.conformance, input.boiling_point, STEAM_EXPANSION_FACTOR
160 ),
161 };
162 PhaseShiftReport {
163 phase: phase.as_status().to_string(),
164 expansion_factor: phase.expansion_factor(),
165 conformance: input.conformance,
166 boiling_point: input.boiling_point,
167 crossed_boiling_point: crossed,
168 summary,
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 fn input(andon: bool, refused: bool, unknown: bool, c: f64, bp: f64) -> PhaseInput {
177 PhaseInput {
178 andon_active: andon,
179 refused,
180 unknown,
181 conformance: c,
182 boiling_point: bp,
183 }
184 }
185
186 #[test]
187 fn precedence_blocked_over_refused_over_unknown() {
188 assert_eq!(
190 phase_for(&input(true, true, true, 0.9, 0.5)),
191 ConformancePhase::Frozen
192 );
193 assert_eq!(
195 phase_for(&input(false, true, true, 0.9, 0.5)),
196 ConformancePhase::Decomposed
197 );
198 assert_eq!(
200 phase_for(&input(false, false, true, 0.9, 0.5)),
201 ConformancePhase::Unsettled
202 );
203 }
204
205 #[test]
206 fn unknown_never_collapses_into_liquid_or_vapor() {
207 let p = phase_for(&input(false, false, true, 1.0, 0.0));
208 assert_eq!(p, ConformancePhase::Unsettled);
209 assert_ne!(p, ConformancePhase::Liquid);
210 assert_ne!(p, ConformancePhase::Vapor);
211 assert_eq!(p.as_status(), "UNKNOWN");
212 }
213
214 #[test]
215 fn boiling_point_boundary_is_admission() {
216 assert_eq!(
218 phase_for(&input(false, false, false, 0.7, 0.7)),
219 ConformancePhase::Vapor
220 );
221 assert_eq!(
222 phase_for(&input(false, false, false, 0.699, 0.7)),
223 ConformancePhase::Liquid
224 );
225 }
226
227 #[test]
228 fn vapor_expands_seventeen_hundred_x() {
229 assert_eq!(ConformancePhase::Vapor.expansion_factor(), 1700);
230 assert_eq!(ConformancePhase::Liquid.expansion_factor(), 1);
231 assert_eq!(ConformancePhase::Frozen.expansion_factor(), 0);
232 assert_eq!(ConformancePhase::Unsettled.expansion_factor(), 0);
233 assert_eq!(ConformancePhase::Decomposed.expansion_factor(), 0);
234 }
235
236 #[test]
237 fn report_marks_crossing_only_for_vapor() {
238 let admitted = phase_shift_report(&input(false, false, false, 0.8, 0.7));
239 assert!(admitted.crossed_boiling_point);
240 assert_eq!(admitted.phase, "ADMITTED");
241 assert_eq!(admitted.expansion_factor, 1700);
242
243 let partial = phase_shift_report(&input(false, false, false, 0.5, 0.7));
244 assert!(!partial.crossed_boiling_point);
245 assert_eq!(partial.phase, "PARTIAL");
246 }
247
248 #[test]
249 fn all_phases_map_to_bounded_statuses() {
250 for p in [
251 ConformancePhase::Frozen,
252 ConformancePhase::Liquid,
253 ConformancePhase::Vapor,
254 ConformancePhase::Unsettled,
255 ConformancePhase::Decomposed,
256 ] {
257 assert!(
258 ["BLOCKED", "PARTIAL", "ADMITTED", "UNKNOWN", "REFUSED"].contains(&p.as_status()),
259 "{p:?} mapped to a non-bounded status"
260 );
261 }
262 }
263}