From 972938a8e62fb4da1a1d8a46ada03ce59d3c88ef Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 19 May 2026 09:16:04 +0800 Subject: [PATCH] Seed public segments and emit signal-conflict alarms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/app_operation_system/src/app.rs | 9 +- .../src/control/engine.rs | 39 ++ .../src/control/interlock.rs | 72 ++++ crates/app_operation_system/src/seed.rs | 358 ++++++++++++------ docs/api-ops.md | 6 +- 5 files changed, 364 insertions(+), 120 deletions(-) diff --git a/crates/app_operation_system/src/app.rs b/crates/app_operation_system/src/app.rs index 881b616..2c4d356 100644 --- a/crates/app_operation_system/src/app.rs +++ b/crates/app_operation_system/src/app.rs @@ -72,14 +72,15 @@ pub async fn run() { .expect("Failed to connect enabled sources"); 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!( - "Seeded kiln templates (stations={}, segments={}, steps={})", + "Seeded default templates (stations={}, segments={}, steps={}, resources={})", report.stations_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), } } diff --git a/crates/app_operation_system/src/control/engine.rs b/crates/app_operation_system/src/control/engine.rs index 23682a2..86d354f 100644 --- a/crates/app_operation_system/src/control/engine.rs +++ b/crates/app_operation_system/src/control/engine.rs @@ -242,6 +242,32 @@ async fn tick( runtime.state, 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 .iter() .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 { + let mut ids: Vec = 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 { steps .iter() diff --git a/crates/app_operation_system/src/control/interlock.rs b/crates/app_operation_system/src/control/interlock.rs index bd1b095..6efd8a6 100644 --- a/crates/app_operation_system/src/control/interlock.rs +++ b/crates/app_operation_system/src/control/interlock.rs @@ -238,6 +238,43 @@ pub fn evaluate_all( 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, +) -> 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)] mod tests { use super::*; @@ -363,6 +400,41 @@ mod tests { 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] fn evaluate_all_returns_first_failure() { let pid_ok = Uuid::new_v4(); diff --git a/crates/app_operation_system/src/seed.rs b/crates/app_operation_system/src/seed.rs index 3b1366b..853bb97 100644 --- a/crates/app_operation_system/src/seed.rs +++ b/crates/app_operation_system/src/seed.rs @@ -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 -//! so operators can start binding equipment / signals through the CRUD APIs -//! instead of authoring every row by hand. Idempotent: re-running is a no-op -//! when the codes already exist. +//! Idempotently inserts the skeleton stations, segments, steps, and shared +//! resource declarations operators need before they can wire equipment and +//! signal bindings through the CRUD APIs. Re-running is a no-op once codes +//! exist. //! -//! Only the *control flow* is seeded; equipment / station-signal bindings stay -//! operator-managed because the field config differs per site. +//! Coverage: +//! +//! - 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; -/// Insert kiln-1 / kiln-2 templates. Returns the number of rows freshly -/// inserted (segments + stations + steps). -pub async fn ensure_kiln_templates(pool: &PgPool) -> Result { +/// Insert kiln + public segment templates. Returns counts so callers can log. +pub async fn ensure_default_templates(pool: &PgPool) -> Result { let mut report = TemplateReport::default(); let mut tx = pool.begin().await?; - for station in kiln_template_stations() { + for station in default_template_stations() { let inserted = sqlx::query( r#" 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 Result Vec { - let lines = [("KILN_1", "1 号"), ("KILN_2", "2 号")]; - let kinds: &[(&str, &str, &str)] = &[ - ("dry_in", "干燥窑进口", "DRY_INFEED"), - ("dry_step", "干燥窑内", "DRY_STEP"), - ("dry_out", "干燥窑出口", "DRY_OUTFEED"), - ]; - let mut out = Vec::with_capacity(lines.len() * kinds.len()); - for (line_code, line_name) in lines { - 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, - name, - line_code, - segment_code, - station_type: kind, - description: "Seeded by ensure_kiln_templates", - }); - } - } +fn default_template_stations() -> Vec { + let mut out = Vec::new(); + out.extend(kiln_template_stations()); + out.extend(public_template_stations()); out } +fn kiln_template_stations() -> Vec { + let dry: &[(&'static str, &'static str, &'static str, &'static str)] = &[ + // (line, code, name, station_type) + ("KILN_1", "ST-DRY1-IN", "1 号干燥窑进口位", "dry_in"), + ("KILN_1", "ST-DRY1-STEP", "1 号干燥窑内位", "dry_step"), + ("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"), + ]; + dry.iter() + .map(|(line, code, name, station_type)| StationTemplate { + code, + name, + 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 { + 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, + station_type, + description: "Seeded by ensure_default_templates", + }) + .collect() +} + struct SegmentTemplate { code: &'static str, name: &'static str, @@ -172,65 +210,74 @@ struct SegmentTemplate { description: &'static str, } -fn kiln_template_segments() -> Vec { +fn default_template_segments() -> Vec { let mut out = Vec::new(); - for (line_code, label) in [("KILN_1", "1 号"), ("KILN_2", "2 号")] { - out.push(SegmentTemplate { - 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.extend(kiln_template_segments()); + out.extend(public_template_segments()); out } +fn kiln_template_segments() -> Vec { + 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 { + 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)] pub struct StepTemplate { pub step_no: i32, @@ -241,7 +288,7 @@ pub struct StepTemplate { 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 { match segment_type { "kiln_infeed" => vec![ @@ -320,6 +367,66 @@ pub fn build_step_template(segment_type: &str) -> Vec { 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(), } } @@ -360,27 +467,48 @@ mod tests { 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] fn unknown_segment_type_yields_empty_template() { assert!(build_step_template("unknown").is_empty()); } #[test] - fn station_template_covers_both_kilns() { - let stations = kiln_template_stations(); - assert_eq!(stations.len(), 6); + fn station_template_covers_kilns_and_public() { + let stations = default_template_stations(); + assert_eq!(stations.len(), 6 + 5); 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] - fn segment_template_covers_three_per_kiln() { - let segments = kiln_template_segments(); - assert_eq!(segments.len(), 6); + fn segment_template_covers_kilns_and_public() { + let segments = default_template_segments(); + assert_eq!(segments.len(), 6 + 6); 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 common = segments.iter().filter(|s| s.line_code == "COMMON").count(); assert_eq!(kiln_1, 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] diff --git a/docs/api-ops.md b/docs/api-ops.md index 0ddc42a..3570618 100644 --- a/docs/api-ops.md +++ b/docs/api-ops.md @@ -78,4 +78,8 @@ ## 环境变量 - `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 完成。