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");
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),
}
}

View File

@ -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<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> {
steps
.iter()

View File

@ -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<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)]
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();

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
//! 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<TemplateReport, sqlx::Error> {
/// Insert kiln + public segment templates. Returns counts so callers can log.
pub async fn ensure_default_templates(pool: &PgPool) -> Result<TemplateReport, sqlx::Error> {
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<TemplateReport, sqlx
report.stations_inserted += inserted.rows_affected();
}
for segment in kiln_template_segments() {
for segment in default_template_segments() {
let inserted = sqlx::query(
r#"
INSERT INTO process_segment (
@ -64,6 +71,23 @@ pub async fn ensure_kiln_templates(pool: &PgPool) -> Result<TemplateReport, sqlx
.await?;
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
// step list, the seed mustn't trample it.
let existing_step_count: i64 =
@ -108,6 +132,7 @@ pub struct TemplateReport {
pub stations_inserted: u64,
pub segments_inserted: u64,
pub steps_inserted: u64,
pub resources_inserted: u64,
}
struct StationTemplate {
@ -119,47 +144,60 @@ struct StationTemplate {
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> {
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 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"),
];
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 {
dry.iter()
.map(|(line, code, name, station_type)| StationTemplate {
code,
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,
station_type: kind,
description: "Seeded by ensure_kiln_templates",
});
}
}
out
station_type,
description: "Seeded by ensure_default_templates",
})
.collect()
}
struct SegmentTemplate {
@ -172,65 +210,74 @@ struct SegmentTemplate {
description: &'static str,
}
fn kiln_template_segments() -> Vec<SegmentTemplate> {
fn default_template_segments() -> Vec<SegmentTemplate> {
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<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)]
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<StepTemplate> {
match segment_type {
"kiln_infeed" => vec![
@ -320,6 +367,66 @@ pub fn build_step_template(segment_type: &str) -> Vec<StepTemplate> {
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]

View File

@ -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 完成。