Seed public segments and emit signal-conflict alarms

Extends ensure_default_templates to also seed the 5 公共段 (前端码车 /
前端放车 / 前端摆渡 / 窑尾摆渡 / 卸砖 / 回车) and their shared resource
declarations (transfer_front / transfer_tail / unload_position /
return_line / robot_arm) so operators have the full skeleton to bind
equipment + signals against.

Engine now runs a station signal-conflict check during the run-halt
phase: any station whose presence and vacancy are both true with Good
quality emits ops.alarm.signal_conflict + segment.fault_locked and
transitions the segment to Faulted. Closes the final P8 alarm type
from design doc §8.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-19 09:16:04 +08:00
parent a7f5c85032
commit 972938a8e6
5 changed files with 364 additions and 120 deletions

View File

@ -72,14 +72,15 @@ pub async fn run() {
.expect("Failed to connect enabled sources"); .expect("Failed to connect enabled sources");
if crate::seed::enabled_via_env() { if crate::seed::enabled_via_env() {
match crate::seed::ensure_kiln_templates(&platform.pool).await { match crate::seed::ensure_default_templates(&platform.pool).await {
Ok(report) => tracing::info!( Ok(report) => tracing::info!(
"Seeded kiln templates (stations={}, segments={}, steps={})", "Seeded default templates (stations={}, segments={}, steps={}, resources={})",
report.stations_inserted, report.stations_inserted,
report.segments_inserted, report.segments_inserted,
report.steps_inserted report.steps_inserted,
report.resources_inserted
), ),
Err(err) => tracing::error!("Seed kiln templates failed: {}", err), Err(err) => tracing::error!("Seed default templates failed: {}", err),
} }
} }

View File

@ -242,6 +242,32 @@ async fn tick(
runtime.state, runtime.state,
SegmentState::Executing | SegmentState::Confirming | SegmentState::Resetting SegmentState::Executing | SegmentState::Confirming | SegmentState::Resetting
) { ) {
// Signal-conflict detection runs first: an impossible station state
// means a sensor or wiring fault, which the engine should not
// continue past regardless of how interlocks evaluate.
let stations_referenced = collect_referenced_stations(steps, interlocks);
if let Err((station_id, message)) =
interlock::check_station_signal_conflicts(&stations_referenced, ctx, monitor)
{
let _ = state.event_manager.send(AppEvent::AlarmSignalConflict {
segment_id: segment.id,
message: message.clone(),
});
let _ = state.event_manager.send(AppEvent::SegmentFaultLocked {
segment_id: segment.id,
message: message.clone(),
});
tracing::warn!(
"Engine: segment {} signal conflict on station {}: {}",
segment.id,
station_id,
message
);
runtime.state = SegmentState::Faulted;
runtime.fault_message = Some(message);
return Some(runtime);
}
let run_halt: Vec<&SegmentInterlock> = interlocks let run_halt: Vec<&SegmentInterlock> = interlocks
.iter() .iter()
.filter(|i| i.applies_to == "run_halt") .filter(|i| i.applies_to == "run_halt")
@ -600,6 +626,19 @@ async fn tick(
} }
} }
/// Collect distinct station ids touched by either a step's `target_station_id`
/// or an interlock's `station_id`. Used for cross-cutting station health checks.
fn collect_referenced_stations(
steps: &[SegmentStep],
interlocks: &[SegmentInterlock],
) -> Vec<Uuid> {
let mut ids: Vec<Uuid> = steps.iter().filter_map(|s| s.target_station_id).collect();
ids.extend(interlocks.iter().filter_map(|i| i.station_id));
ids.sort();
ids.dedup();
ids
}
fn next_sequential(steps: &[SegmentStep], current: i32) -> Option<i32> { fn next_sequential(steps: &[SegmentStep], current: i32) -> Option<i32> {
steps steps
.iter() .iter()

View File

@ -238,6 +238,43 @@ pub fn evaluate_all(
Ok(()) Ok(())
} }
/// Check each station for impossible signal states (design doc §8.1
/// `ops.alarm.signal_conflict`). Currently flags any station whose `presence`
/// and `vacancy` are both logically true at the same time with Good quality.
///
/// Returns `Ok(())` when all stations are consistent. Returns
/// `Err((station_id, reason))` for the first station with a conflict.
pub fn check_station_signal_conflicts(
station_ids: &[Uuid],
ctx: &InterlockContext,
monitor: &HashMap<Uuid, PointMonitorInfo>,
) -> Result<(), (Uuid, String)> {
for station_id in station_ids {
let Some(roles) = ctx.station_role_points.get(station_id) else {
continue;
};
let presence = roles
.get("presence")
.and_then(|(pid, invert)| read_logical_bool(monitor, *pid, *invert));
let vacancy = roles
.get("vacancy")
.and_then(|(pid, invert)| read_logical_bool(monitor, *pid, *invert));
match (presence, vacancy) {
(Some(true), Some(true)) => {
return Err((
*station_id,
format!(
"station {} reports presence=true and vacancy=true simultaneously",
station_id
),
));
}
_ => continue,
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -363,6 +400,41 @@ mod tests {
assert!(evaluate(&rule, &ctx, &monitor).is_err()); assert!(evaluate(&rule, &ctx, &monitor).is_err());
} }
#[test]
fn station_signal_conflict_flags_simultaneous_presence_and_vacancy() {
let station_id = Uuid::new_v4();
let presence_pid = Uuid::new_v4();
let vacancy_pid = Uuid::new_v4();
let mut roles = HashMap::new();
roles.insert("presence".to_string(), (presence_pid, false));
roles.insert("vacancy".to_string(), (vacancy_pid, false));
let mut station_role_points = HashMap::new();
station_role_points.insert(station_id, roles);
let ctx = InterlockContext {
equipment_role_points: HashMap::new(),
station_role_points,
};
let mut monitor = HashMap::new();
monitor.insert(presence_pid, monitor_entry(presence_pid, true, true));
monitor.insert(vacancy_pid, monitor_entry(vacancy_pid, true, true));
let err = check_station_signal_conflicts(&[station_id], &ctx, &monitor)
.expect_err("conflict should surface");
assert_eq!(err.0, station_id);
}
#[test]
fn station_signal_conflict_ignores_missing_signals() {
let station_id = Uuid::new_v4();
let ctx = InterlockContext {
equipment_role_points: HashMap::new(),
station_role_points: HashMap::new(),
};
assert!(check_station_signal_conflicts(&[station_id], &ctx, &HashMap::new()).is_ok());
}
#[test] #[test]
fn evaluate_all_returns_first_failure() { fn evaluate_all_returns_first_failure() {
let pid_ok = Uuid::new_v4(); let pid_ok = Uuid::new_v4();

View File

@ -1,22 +1,29 @@
//! Kiln template seed (design doc §12 P5). //! Default segment templates (design doc §12 P5 + P7).
//! //!
//! Inserts skeleton stations + segments + steps for 1 号 / 2 号 dry-kiln lines //! Idempotently inserts the skeleton stations, segments, steps, and shared
//! so operators can start binding equipment / signals through the CRUD APIs //! resource declarations operators need before they can wire equipment and
//! instead of authoring every row by hand. Idempotent: re-running is a no-op //! signal bindings through the CRUD APIs. Re-running is a no-op once codes
//! when the codes already exist. //! exist.
//! //!
//! Only the *control flow* is seeded; equipment / station-signal bindings stay //! Coverage:
//! operator-managed because the field config differs per site. //!
//! - 6 dry-kiln segments (infeed / step / outfeed × 2 kilns).
//! - 6 public segments (front load / front release / front transfer /
//! tail transfer / unload / return) wiring kiln 1 + kiln 2 with shared
//! resource keys (`transfer_front`, `transfer_tail`, `unload_position`,
//! `return_line`, `robot_arm`).
//!
//! Equipment / station-signal bindings stay operator-managed because the
//! field config differs per site.
use sqlx::PgPool; use sqlx::PgPool;
/// Insert kiln-1 / kiln-2 templates. Returns the number of rows freshly /// Insert kiln + public segment templates. Returns counts so callers can log.
/// inserted (segments + stations + steps). pub async fn ensure_default_templates(pool: &PgPool) -> Result<TemplateReport, sqlx::Error> {
pub async fn ensure_kiln_templates(pool: &PgPool) -> Result<TemplateReport, sqlx::Error> {
let mut report = TemplateReport::default(); let mut report = TemplateReport::default();
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
for station in kiln_template_stations() { for station in default_template_stations() {
let inserted = sqlx::query( let inserted = sqlx::query(
r#" r#"
INSERT INTO station (code, name, line_code, segment_code, station_type, enabled, description) INSERT INTO station (code, name, line_code, segment_code, station_type, enabled, description)
@ -35,7 +42,7 @@ pub async fn ensure_kiln_templates(pool: &PgPool) -> Result<TemplateReport, sqlx
report.stations_inserted += inserted.rows_affected(); report.stations_inserted += inserted.rows_affected();
} }
for segment in kiln_template_segments() { for segment in default_template_segments() {
let inserted = sqlx::query( let inserted = sqlx::query(
r#" r#"
INSERT INTO process_segment ( INSERT INTO process_segment (
@ -64,6 +71,23 @@ pub async fn ensure_kiln_templates(pool: &PgPool) -> Result<TemplateReport, sqlx
.await?; .await?;
let Some(segment_id) = segment_id else { continue }; let Some(segment_id) = segment_id else { continue };
// Resource declarations are idempotent at the row level (UNIQUE
// constraint). Insert each declared key.
for resource_key in default_segment_resources(segment.code) {
let inserted = sqlx::query(
r#"
INSERT INTO segment_resource (segment_id, resource_key)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
)
.bind(segment_id)
.bind(resource_key)
.execute(&mut *tx)
.await?;
report.resources_inserted += inserted.rows_affected();
}
// Only seed steps for an empty segment. Once an operator owns the // Only seed steps for an empty segment. Once an operator owns the
// step list, the seed mustn't trample it. // step list, the seed mustn't trample it.
let existing_step_count: i64 = let existing_step_count: i64 =
@ -108,6 +132,7 @@ pub struct TemplateReport {
pub stations_inserted: u64, pub stations_inserted: u64,
pub segments_inserted: u64, pub segments_inserted: u64,
pub steps_inserted: u64, pub steps_inserted: u64,
pub resources_inserted: u64,
} }
struct StationTemplate { struct StationTemplate {
@ -119,47 +144,60 @@ struct StationTemplate {
description: &'static str, description: &'static str,
} }
fn default_template_stations() -> Vec<StationTemplate> {
let mut out = Vec::new();
out.extend(kiln_template_stations());
out.extend(public_template_stations());
out
}
fn kiln_template_stations() -> Vec<StationTemplate> { fn kiln_template_stations() -> Vec<StationTemplate> {
let lines = [("KILN_1", "1 号"), ("KILN_2", "2 号")]; let dry: &[(&'static str, &'static str, &'static str, &'static str)] = &[
let kinds: &[(&str, &str, &str)] = &[ // (line, code, name, station_type)
("dry_in", "干燥窑进口", "DRY_INFEED"), ("KILN_1", "ST-DRY1-IN", "1 号干燥窑进口位", "dry_in"),
("dry_step", "干燥窑内", "DRY_STEP"), ("KILN_1", "ST-DRY1-STEP", "1 号干燥窑内位", "dry_step"),
("dry_out", "干燥窑出口", "DRY_OUTFEED"), ("KILN_1", "ST-DRY1-OUT", "1 号干燥窑出口位", "dry_out"),
("KILN_2", "ST-DRY2-IN", "2 号干燥窑进口位", "dry_in"),
("KILN_2", "ST-DRY2-STEP", "2 号干燥窑内位", "dry_step"),
("KILN_2", "ST-DRY2-OUT", "2 号干燥窑出口位", "dry_out"),
]; ];
let mut out = Vec::with_capacity(lines.len() * kinds.len()); dry.iter()
for (line_code, line_name) in lines { .map(|(line, code, name, station_type)| StationTemplate {
for (kind, kind_name, segment_code) in kinds {
let code = match (line_code, *kind) {
("KILN_1", "dry_in") => "ST-DRY1-IN",
("KILN_1", "dry_step") => "ST-DRY1-STEP",
("KILN_1", "dry_out") => "ST-DRY1-OUT",
("KILN_2", "dry_in") => "ST-DRY2-IN",
("KILN_2", "dry_step") => "ST-DRY2-STEP",
("KILN_2", "dry_out") => "ST-DRY2-OUT",
_ => continue,
};
let name: &'static str = match (line_code, *kind) {
("KILN_1", "dry_in") => "1 号干燥窑进口位",
("KILN_1", "dry_step") => "1 号干燥窑内位",
("KILN_1", "dry_out") => "1 号干燥窑出口位",
("KILN_2", "dry_in") => "2 号干燥窑进口位",
("KILN_2", "dry_step") => "2 号干燥窑内位",
("KILN_2", "dry_out") => "2 号干燥窑出口位",
_ => continue,
};
// Description is interned via &'static str by matching above; no need to format.
let _ = (line_name, kind_name); // documentation-only context
out.push(StationTemplate {
code, code,
name, name,
line_code, line_code: line,
segment_code: match *station_type {
"dry_in" => "DRY_INFEED",
"dry_step" => "DRY_STEP",
"dry_out" => "DRY_OUTFEED",
_ => "DRY",
},
station_type,
description: "Seeded by ensure_default_templates",
})
.collect()
}
fn public_template_stations() -> Vec<StationTemplate> {
let stations: &[(&'static str, &'static str, &'static str, &'static str)] = &[
// (code, name, segment_code, station_type)
("ST-FRONT-LOAD", "前端码车位", "FRONT_LOAD", "load"),
("ST-FRONT-TRANSFER", "前端摆渡接车位", "FRONT_TRANSFER", "transfer"),
("ST-TAIL-TRANSFER", "窑尾摆渡接车位", "TAIL_TRANSFER", "transfer"),
("ST-UNLOAD", "卸砖机位", "UNLOAD", "unload"),
("ST-RETURN-IN", "回车线入口位", "RETURN", "return"),
];
stations
.iter()
.map(|(code, name, segment_code, station_type)| StationTemplate {
code,
name,
line_code: "COMMON",
segment_code, segment_code,
station_type: kind, station_type,
description: "Seeded by ensure_kiln_templates", description: "Seeded by ensure_default_templates",
}); })
} .collect()
}
out
} }
struct SegmentTemplate { struct SegmentTemplate {
@ -172,65 +210,74 @@ struct SegmentTemplate {
description: &'static str, description: &'static str,
} }
fn kiln_template_segments() -> Vec<SegmentTemplate> { fn default_template_segments() -> Vec<SegmentTemplate> {
let mut out = Vec::new(); let mut out = Vec::new();
for (line_code, label) in [("KILN_1", "1 号"), ("KILN_2", "2 号")] { out.extend(kiln_template_segments());
out.push(SegmentTemplate { out.extend(public_template_segments());
code: if line_code == "KILN_1" {
"SEG-DRY1-INFEED"
} else {
"SEG-DRY2-INFEED"
},
name: if line_code == "KILN_1" {
"1 号干燥窑进口段"
} else {
"2 号干燥窑进口段"
},
segment_type: "kiln_infeed",
line_code,
priority: 10,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
out.push(SegmentTemplate {
code: if line_code == "KILN_1" {
"SEG-DRY1-STEP"
} else {
"SEG-DRY2-STEP"
},
name: if line_code == "KILN_1" {
"1 号干燥窑内前移段"
} else {
"2 号干燥窑内前移段"
},
segment_type: "kiln_step",
line_code,
priority: 5,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
out.push(SegmentTemplate {
code: if line_code == "KILN_1" {
"SEG-DRY1-OUTFEED"
} else {
"SEG-DRY2-OUTFEED"
},
name: if line_code == "KILN_1" {
"1 号干燥窑出口段"
} else {
"2 号干燥窑出口段"
},
segment_type: "kiln_outfeed",
line_code,
priority: 10,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
});
let _ = label;
}
out out
} }
fn kiln_template_segments() -> Vec<SegmentTemplate> {
let entries: &[(&'static str, &'static str, &'static str, &'static str, i32)] = &[
// (line, code, name, segment_type, priority)
("KILN_1", "SEG-DRY1-INFEED", "1 号干燥窑进口段", "kiln_infeed", 10),
("KILN_1", "SEG-DRY1-STEP", "1 号干燥窑内前移段", "kiln_step", 5),
("KILN_1", "SEG-DRY1-OUTFEED", "1 号干燥窑出口段", "kiln_outfeed", 10),
("KILN_2", "SEG-DRY2-INFEED", "2 号干燥窑进口段", "kiln_infeed", 10),
("KILN_2", "SEG-DRY2-STEP", "2 号干燥窑内前移段", "kiln_step", 5),
("KILN_2", "SEG-DRY2-OUTFEED", "2 号干燥窑出口段", "kiln_outfeed", 10),
];
entries
.iter()
.map(|(line, code, name, segment_type, priority)| SegmentTemplate {
code,
name,
segment_type,
line_code: line,
priority: *priority,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
})
.collect()
}
fn public_template_segments() -> Vec<SegmentTemplate> {
let entries: &[(&'static str, &'static str, &'static str, i32)] = &[
// (code, name, segment_type, priority)
("SEG-FRONT-LOAD", "前端码车位进车段", "front_load", 10),
("SEG-FRONT-RELEASE", "前端码车位放车段", "front_release", 10),
("SEG-FRONT-TRANSFER", "前端摆渡分配段", "front_transfer", 8),
("SEG-TAIL-TRANSFER", "窑尾摆渡接车段", "tail_transfer", 8),
("SEG-UNLOAD", "卸砖机位段", "unload", 6),
("SEG-RETURN", "回车线入口段", "return", 4),
];
entries
.iter()
.map(|(code, name, segment_type, priority)| SegmentTemplate {
code,
name,
segment_type,
line_code: "COMMON",
priority: *priority,
mode: "disabled",
description: "Seeded skeleton; bind equipment + station signals to enable.",
})
.collect()
}
/// Shared-resource keys per public segment (design doc §7).
/// Returns `[]` for segments that don't claim a public resource.
pub fn default_segment_resources(segment_code: &str) -> Vec<&'static str> {
match segment_code {
"SEG-FRONT-LOAD" | "SEG-FRONT-RELEASE" => vec!["robot_arm"],
"SEG-FRONT-TRANSFER" => vec!["transfer_front"],
"SEG-TAIL-TRANSFER" => vec!["transfer_tail"],
"SEG-UNLOAD" => vec!["unload_position"],
"SEG-RETURN" => vec!["return_line"],
_ => vec![],
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StepTemplate { pub struct StepTemplate {
pub step_no: i32, pub step_no: i32,
@ -241,7 +288,7 @@ pub struct StepTemplate {
pub description: &'static str, pub description: &'static str,
} }
/// Per-segment-type canonical step list (design doc §7.4 / §10.1). /// Per-segment-type canonical step list (design doc §7.4 / §10).
pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> { pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> {
match segment_type { match segment_type {
"kiln_infeed" => vec![ "kiln_infeed" => vec![
@ -320,6 +367,66 @@ pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> {
description: "关门并等待门关到位", description: "关门并等待门关到位",
}, },
], ],
"front_load" => vec![
StepTemplate {
step_no: 1,
step_code: "ROBOT_PERMIT",
action_kind: "robot_permit",
confirm_signal_role: "done",
timeout_ms: 30_000,
description: "允许机械臂码坯并等待完成",
},
StepTemplate {
step_no: 2,
step_code: "WAIT_RELEASE_READY",
action_kind: "wait_signal",
confirm_signal_role: "arrived",
timeout_ms: 60_000,
description: "等待码车位放车确认",
},
],
"front_release" => vec![StepTemplate {
step_no: 1,
step_code: "ROBOT_RELEASE",
action_kind: "robot_release",
confirm_signal_role: "done",
timeout_ms: 30_000,
description: "码车放车并等待完成",
}],
"front_transfer" | "tail_transfer" => vec![
StepTemplate {
step_no: 1,
step_code: "MOVE_TO_TARGET",
action_kind: "transfer_move_to",
confirm_signal_role: "arrived",
timeout_ms: 60_000,
description: "摆渡车定位到目标工位",
},
StepTemplate {
step_no: 2,
step_code: "WAIT_HANDOFF",
action_kind: "wait_signal",
confirm_signal_role: "done",
timeout_ms: 60_000,
description: "等待上下游交接完成",
},
],
"unload" => vec![StepTemplate {
step_no: 1,
step_code: "STEP_ONCE",
action_kind: "step_once",
confirm_signal_role: "arrived",
timeout_ms: 30_000,
description: "卸砖位步进一格并等待到位",
}],
"return" => vec![StepTemplate {
step_no: 1,
step_code: "STEP_ONCE",
action_kind: "step_once",
confirm_signal_role: "arrived",
timeout_ms: 30_000,
description: "回车线步进一格并等待到位",
}],
_ => Vec::new(), _ => Vec::new(),
} }
} }
@ -360,27 +467,48 @@ mod tests {
assert!(steps.iter().any(|s| s.action_kind == "pull_retract")); assert!(steps.iter().any(|s| s.action_kind == "pull_retract"));
} }
#[test]
fn transfer_segments_use_move_to_action() {
let steps = build_step_template("front_transfer");
assert!(steps.iter().any(|s| s.action_kind == "transfer_move_to"));
let steps = build_step_template("tail_transfer");
assert!(steps.iter().any(|s| s.action_kind == "transfer_move_to"));
}
#[test] #[test]
fn unknown_segment_type_yields_empty_template() { fn unknown_segment_type_yields_empty_template() {
assert!(build_step_template("unknown").is_empty()); assert!(build_step_template("unknown").is_empty());
} }
#[test] #[test]
fn station_template_covers_both_kilns() { fn station_template_covers_kilns_and_public() {
let stations = kiln_template_stations(); let stations = default_template_stations();
assert_eq!(stations.len(), 6); assert_eq!(stations.len(), 6 + 5);
assert!(stations.iter().any(|s| s.code == "ST-DRY1-IN")); assert!(stations.iter().any(|s| s.code == "ST-DRY1-IN"));
assert!(stations.iter().any(|s| s.code == "ST-DRY2-OUT")); assert!(stations.iter().any(|s| s.code == "ST-FRONT-LOAD"));
assert!(stations.iter().any(|s| s.code == "ST-RETURN-IN"));
} }
#[test] #[test]
fn segment_template_covers_three_per_kiln() { fn segment_template_covers_kilns_and_public() {
let segments = kiln_template_segments(); let segments = default_template_segments();
assert_eq!(segments.len(), 6); assert_eq!(segments.len(), 6 + 6);
let kiln_1 = segments.iter().filter(|s| s.line_code == "KILN_1").count(); let kiln_1 = segments.iter().filter(|s| s.line_code == "KILN_1").count();
let kiln_2 = segments.iter().filter(|s| s.line_code == "KILN_2").count(); let kiln_2 = segments.iter().filter(|s| s.line_code == "KILN_2").count();
let common = segments.iter().filter(|s| s.line_code == "COMMON").count();
assert_eq!(kiln_1, 3); assert_eq!(kiln_1, 3);
assert_eq!(kiln_2, 3); assert_eq!(kiln_2, 3);
assert_eq!(common, 6);
}
#[test]
fn resource_keys_match_design_doc_section_7() {
assert_eq!(default_segment_resources("SEG-FRONT-TRANSFER"), vec!["transfer_front"]);
assert_eq!(default_segment_resources("SEG-TAIL-TRANSFER"), vec!["transfer_tail"]);
assert_eq!(default_segment_resources("SEG-UNLOAD"), vec!["unload_position"]);
assert_eq!(default_segment_resources("SEG-RETURN"), vec!["return_line"]);
assert_eq!(default_segment_resources("SEG-FRONT-LOAD"), vec!["robot_arm"]);
assert!(default_segment_resources("SEG-DRY1-INFEED").is_empty());
} }
#[test] #[test]

View File

@ -78,4 +78,8 @@
## 环境变量 ## 环境变量
- `SIMULATE_PLC=1` — 调试模式,引擎发出命令后通过模拟器把确认信号写回监控缓存,使段流程可在无 PLC 现场时端到端运行。 - `SIMULATE_PLC=1` — 调试模式,引擎发出命令后通过模拟器把确认信号写回监控缓存,使段流程可在无 PLC 现场时端到端运行。
- `OPS_SEED_TEMPLATES=1` — 应用启动时自动写入 1 号 / 2 号干燥窑 6 段infeed / step / outfeed × 2的段 + 步骤骨架,仅插入缺失的记录,不覆盖已有配置。设备与工位信号绑定仍需通过 CRUD API 完成。 - `OPS_SEED_TEMPLATES=1` — 应用启动时自动写入默认骨架:
- 6 个干燥窑段infeed / step / outfeed × 1 号 / 2 号)
- 6 个公共段(前端码车 / 前端放车 / 前端摆渡 / 窑尾摆渡 / 卸砖 / 回车),并写入对应共享资源 key`transfer_front` / `transfer_tail` / `unload_position` / `return_line` / `robot_arm`
- 关联工位(含 5 个公共工位)
- 仅插入缺失的记录,不覆盖已有配置。设备与工位信号绑定仍需通过 CRUD API 完成。