use serde::{Deserialize, Serialize};
#[cfg(not(feature = "std"))]
use alloc::{format, string::{String, ToString}};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RecipeStep {
GasStabilize,
MainEtch,
Deposition,
OverEtch,
Seasoning,
Other(String),
}
impl Default for RecipeStep {
fn default() -> Self {
Self::Other("unknown".into())
}
}
impl RecipeStep {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"gas_stabilize" | "gas stabilize" | "gasstabilize" | "stabilize" => {
Self::GasStabilize
}
"main_etch" | "main etch" | "mainetch" | "etch" => Self::MainEtch,
"deposition" | "dep" | "cvd" | "pvd" | "ald" => Self::Deposition,
"over_etch" | "over etch" | "overetch" => Self::OverEtch,
"seasoning" | "season" | "conditioning" => Self::Seasoning,
other => Self::Other(other.to_string()),
}
}
pub fn display_name(&self) -> &str {
match self {
Self::GasStabilize => "GasStabilize",
Self::MainEtch => "MainEtch",
Self::Deposition => "Deposition",
Self::OverEtch => "OverEtch",
Self::Seasoning => "Seasoning",
Self::Other(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum ToolState {
#[default]
Production,
ChamberClean,
Seasoning,
Maintenance,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProcessContext {
pub recipe_step_id: String,
pub recipe_step: RecipeStep,
pub tool_state: ToolState,
pub lot_id: Option<String>,
pub chamber_id: Option<String>,
}
impl ProcessContext {
#[must_use]
pub fn admissibility_multiplier(&self) -> f64 {
if self.tool_state == ToolState::ChamberClean {
return f64::INFINITY; }
match &self.recipe_step {
RecipeStep::GasStabilize => 1.50,
RecipeStep::MainEtch => 0.80,
RecipeStep::Deposition => 1.10,
RecipeStep::OverEtch => 1.20,
RecipeStep::Seasoning => 2.00,
RecipeStep::Other(_) => 1.00,
}
}
#[must_use]
pub fn requires_warm_reset(&self) -> bool {
matches!(
self.tool_state,
ToolState::ChamberClean | ToolState::Seasoning | ToolState::Maintenance
)
}
pub fn traceability_tag(&self) -> String {
format!(
"step={} tool_state={:?} lot={} chamber={}",
self.recipe_step.display_name(),
self.tool_state,
self.lot_id.as_deref().unwrap_or("none"),
self.chamber_id.as_deref().unwrap_or("none"),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenanceHysteresis {
pub post_clean_guard_runs: usize,
reset_pending: bool,
runs_since_reset: usize,
}
impl Default for MaintenanceHysteresis {
fn default() -> Self {
Self::new(10)
}
}
impl MaintenanceHysteresis {
pub fn new(post_clean_guard_runs: usize) -> Self {
Self {
post_clean_guard_runs,
reset_pending: false,
runs_since_reset: usize::MAX,
}
}
pub fn update(&mut self, ctx: &ProcessContext) -> bool {
if ctx.requires_warm_reset() {
self.reset_pending = true;
self.runs_since_reset = 0;
return true;
}
if self.reset_pending {
self.runs_since_reset = self.runs_since_reset.saturating_add(1);
if self.runs_since_reset >= self.post_clean_guard_runs {
self.reset_pending = false;
self.runs_since_reset = usize::MAX;
}
}
false
}
#[must_use]
pub fn is_suppressed(&self) -> bool {
self.reset_pending
}
#[must_use]
pub fn runs_since_last_reset(&self) -> usize {
self.runs_since_reset
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn main_etch_tightens_envelope() {
let ctx = ProcessContext {
recipe_step: RecipeStep::MainEtch,
tool_state: ToolState::Production,
..Default::default()
};
assert!(
(ctx.admissibility_multiplier() - 0.80).abs() < 1e-9,
"MainEtch should yield 0.80× multiplier"
);
}
#[test]
fn gas_stabilize_relaxes_envelope() {
let ctx = ProcessContext {
recipe_step: RecipeStep::GasStabilize,
tool_state: ToolState::Production,
..Default::default()
};
assert!(
(ctx.admissibility_multiplier() - 1.50).abs() < 1e-9,
"GasStabilize should yield 1.50× multiplier"
);
}
#[test]
fn chamber_clean_suppresses_everything() {
let ctx = ProcessContext {
recipe_step: RecipeStep::MainEtch,
tool_state: ToolState::ChamberClean,
..Default::default()
};
assert!(
ctx.admissibility_multiplier().is_infinite(),
"ChamberClean should yield MAX multiplier (full suppression)"
);
}
#[test]
fn chamber_clean_requires_warm_reset() {
let ctx = ProcessContext {
tool_state: ToolState::ChamberClean,
..Default::default()
};
assert!(ctx.requires_warm_reset());
}
#[test]
fn production_does_not_require_warm_reset() {
let ctx = ProcessContext {
tool_state: ToolState::Production,
..Default::default()
};
assert!(!ctx.requires_warm_reset());
}
#[test]
fn hysteresis_guard_window_elapses() {
let mut hyst = MaintenanceHysteresis::new(3);
let mut clean = ProcessContext::default();
clean.tool_state = ToolState::ChamberClean;
assert!(hyst.update(&clean));
assert!(hyst.is_suppressed());
let mut prod = ProcessContext::default();
prod.tool_state = ToolState::Production;
hyst.update(&prod);
assert!(hyst.is_suppressed());
hyst.update(&prod);
assert!(hyst.is_suppressed());
hyst.update(&prod);
assert!(!hyst.is_suppressed(), "guard window should have expired after 3 runs");
}
#[test]
fn recipe_step_from_str_round_trips() {
assert_eq!(RecipeStep::from_str("main_etch"), RecipeStep::MainEtch);
assert_eq!(RecipeStep::from_str("GAS STABILIZE"), RecipeStep::GasStabilize);
assert_eq!(RecipeStep::from_str("seasoning"), RecipeStep::Seasoning);
assert!(matches!(
RecipeStep::from_str("plasma_clean"),
RecipeStep::Other(_)
));
}
}