- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드 - LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드 - KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트 - MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선 - Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가 - 설정: AGENTS.md, plant_context, README, opencode.json 업데이트 - 정리: 진단 체크리스트 문서 삭제
65 KiB
Experion 전체 알람 추가 플랜
⚠️ 재진단 (2026-05-20)
기준: diagnosis-checklist.md STEP 1
8 | 대상: 플랜 내 코드 템플릿 (Phase AE) 방법: 실제 소스 코드(IExperionServices.cs, ExperionDbContext.cs, ExperionEntities.cs, Program.cs, ExperionControllers.cs)와 교차 검증
재진단 결론: 6개 항목 유지 + 3개 신규 발견 + 1개 심각도 상향
| # | 항목 | 이전 | 재진단 | 변경 사유 |
|---|---|---|---|---|
| [1] | LoadSystemAlarmStatesAsync 빈사전 | HIGH | HIGH 유지 | D4 템플릿:897-900 빈사전 초기화 확인, Q4 통과 |
| [2] | RefreshPvCacheAsync 비효율 | MED | MED 유지 | D2 템플릿:976-982 전체 957건 로드 확인, Q4 통과 |
| [3] | DeterminePointEventType 불완전 | MED | 🔴 HIGH 상향 | inalm/unackn 조합 미검증 → 잘못된 NORMAL 이벤트 발생, Q4 통과 |
| [4] | Scan 엔드포인트 탐색 한계 | LOW | LOW 유지 | node_map_master 의존성 확인, Q4 통과 |
| [5] | Clear 엔드포인트 성능 | LOW | LOW 유지 | E3 템플릿 O(n*m) 확인, Q4 통과 |
| [6] | _pvCache 갱신 없음 | LOW | LOW 유지 | D2 템플릿:935 최초 1회만 로드, Q4 통과 |
| [7] | AppendPointAlarmNodesAsync 필터 누락 | — | 🟠 MED 신규 | C4 템플릿:766-770 level=3 + inalm/unackn만 필터, 394개 base tag 매칭 없음 |
| [8] | SaveSystemAlarmConfigsAsync upsert 미구현 | — | 🟠 MED 신규 | C7 템플릿:826-829 AddRange만 호출, ON CONFLICT 없음 |
| [9] | DetectPointAlarmChangesAsync unackn 미검증 | — | 🔴 HIGH 신규 | D3 템플릿:1070-1073 inalm 1→0 시 unackn=0인지 확인 안 함 |
[3] 심각도 상향 근거
이전: "unackn이 0→1로 변하는 미확인 알람 발생 이벤트를 감지하지 못함" (MED) 재진단: inalm 1→0일 때 unackn 상태를 확인하지 않으면 알람이 아직 활성인데 NORMAL로 기록되는 false positive 발생. 이는 운전원이 알람 해제를 잘못 판단하게 하는 안전 관련 오진으로 HIGH로 상향.
재현 시나리오: inalm=1, unackn=1 (알람 활성+미확인) 상태에서 inalm이 일시적으로 0→1로 플리커링하면, 현재 코드는 unackn을 확인하지 않고 NORMAL 이벤트를 기록함.
[7] AppendPointAlarmNodesAsync 필터 누락 (MED) — 신규
문제: C4 템플릿(ExperionDbContext.cs 코드)이 level=3 AND (name='inalm' OR name='unackn')로만 필터링. 하지만 node_map_master에 inalm/unackn은 1,416개(707+709) 존재. 플랜 3.3에서 명시한 대로 기존 .pv base tag와 매칭되는 394개만 추가해야 함.
근거: C4 템플릿:766-770
var alarmNodes = await _ctx.NodeMapMasters
.Where(n => n.Level == 3 && (n.Name == "inalm" || n.Name == "unackn"))
.Select(n => n.NodeId)
.ToListAsync(); // ← 1,416건 전체, base tag 필터 없음
영향: 불필요한 1,022개(1,416-394) 노드가 realtime_table에 추가되어 메모리/성능 낭비 + OPC UA subscription 과부하.
수정: 기존 .pv base tag와 JOIN하여 대응되는 alarm 노드만 필터:
var pvBaseTags = await _ctx.RealtimePoints
.Where(p => p.TagName.EndsWith(".pv"))
.Select(p => p.TagName[..^3]) // .pv 제거 → base tag
.Distinct()
.ToListAsync();
var alarmNodes = await _ctx.NodeMapMasters
.Where(n => n.Level == 3 && (n.Name == "inalm" || n.Name == "unackn"))
.Where(n => pvBaseTags.Any(bt => n.NodeId.Contains(bt)))
.Select(n => n.NodeId)
.ToListAsync();
[8] SaveSystemAlarmConfigsAsync upsert 미구현 (MED) — 신규
문제: B1 인터페이스와 C7 템플릿에서 "upsert"라고 명시하지만, 실제 구현은 AddRange + SaveChangesAsync만 호출. system_alarm_config 테이블의 node_id UNIQUE 제약으로 인해 2차 실행 시 DuplicateKeyError 발생.
근거: C7 템플릿:826-829
public async Task<int> SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig> configs)
{
_ctx.Set<SystemAlarmConfig>().AddRange(configs); // ← INSERT만
return await _ctx.SaveChangesAsync();
}
영향: /api/system-alarms/scan을 2회 이상 실행하면 500 에러. 운전원이 재스캔할 수 없음.
수정: INSERT ... ON CONFLICT(node_id) DO UPDATE 사용:
public async Task<int> SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig> configs)
{
var count = configs.Count;
await _ctx.Database.ExecuteSqlRawAsync($@"
INSERT INTO system_alarm_config (instance_name, instance_type, attr_name, node_id, tagname, display_order)
VALUES {string.Join(", ", configs.Select((c, i) => $"(@p{i*6}, @p{i*6+1}, @p{i*6+2}, @p{i*6+3}, @p{i*6+4}, @p{i*6+5})"))}
ON CONFLICT (node_id) DO UPDATE SET
instance_name = EXCLUDED.instance_name,
attr_name = EXCLUDED.attr_name,
tagname = EXCLUDED.tagname
", /* params */);
return count;
}
[9] DetectPointAlarmChangesAsync unackn 미검증 (HIGH) — 신규
문제: D3 템플릿의 DeterminePointEventType에서 isInalm && prevBool && !currBool 시 무조건 "NORMAL" 반환. 하지만 플랜 3.2의 상태 머신에 따르면 inalm 1→0 + unackn=0일 때만 NORMAL. 현재 코드는 unackn 상태를 확인하지 않음.
근거: D3 템플릿:1070-1073
if (isInalm)
{
if (!prevBool && currBool) return "ALARM"; // OK
if (prevBool && !currBool) return "NORMAL"; // ← unackn 확인 없음!
}
영향: inalm=1, unackn=1 상태에서 inalm이 일시적으로 0으로 떨어졌다 다시 1로 복귀하는 플리커링 시, NORMAL 이벤트를 잘못 기록. 운전원이 "알람 해제됨"으로 오인.
수정: Point alarm 감지를 inalm/unackn 조합 기반으로 변경 (플랜 [3]의 수정안과 동일하게):
private string? DeterminePointEventType(bool isInalm, bool isUnackn, string? prevValue, string currValue, string? currentUnacknValue)
{
bool prevBool = prevValue == "True" || prevValue == "1";
bool currBool = currValue == "True" || currValue == "1";
bool currUnackn = currentUnacknValue == "True" || currentUnacknValue == "1";
if (isInalm)
{
if (!prevBool && currBool) return "ALARM";
if (prevBool && !currBool && !currUnackn) return "NORMAL"; // unackn=0 확인
}
// ...
}
교차 검증 결과 (STEP 6)
| 항목 | Q1 수정됨? | Q2 다른 레이어? | Q3 의도적? | Q4 재현 시나리오? | 결론 |
|---|---|---|---|---|---|
| [1] | 아니오 | 아니오 | 아니오 | 예: 시작 시 감지 불가 | 유지 |
| [2] | 아니오 | 아니오 | 아니오 | 예: 957건 전체 로드 | 유지 |
| [3] | 아니오 | 아니오 | 아니오 | 예: 플리커링 시 오진 | 상향 |
| [4] | 아니오 | 아니오 | 아니오 | 예: 0건 리턴 | 유지 |
| [5] | 아니오 | 아니오 | 아니오 | 예: 10k 포인트에서 지연 | 유지 |
| [6] | 아니오 | 아니오 | 아니오 | 예: stale PV 기록 | 유지 |
| [7] | 아니오 | 아니오 | 아니오 | 예: 1,416건 오버프로비저닝 | 신규 |
| [8] | 아니오 | 아니오 | 아니오 | 예: 2차 스캔 시 500 | 신규 |
| [9] | 아니오 | 아니오 | 아니오 | 예: 플리커링 시 false NORMAL | 신규 |
진단 및 개선 권고사항 (원본)
진단일: 2026-05-20 | 기준: diagnosis-checklist.md (STEP 1
8) | 교차검증: Q1Q4 모두 통과한 항목만 포함
[1]. LoadSystemAlarmStatesAsync — 상태 사전 로드 누락 (HIGH)
문제: LoadSystemAlarmStatesAsync()에서 system_alarm_config 그룹만 순회하고 realtime_table 현재값을 조회하지 않아 _systemStates의 값 사전이 모두 빈 상태(new Dictionary<string, string?>())로 초기화됨. 첫 감지 루프에서 prevState.Values가 모두 비어있어 TryGetValue가 항상 null을 반환 → 상태 전이 감지 불가.
근거: 엑스페리온-전체알람-추가플랜.md:892-903 (D2 코드 템플릿)
foreach (var g in groups)
{
_systemStates[g.Key] = new SystemAlarmState(
new Dictionary<string, string?>(), DateTime.UtcNow, null); // ← 빈 사전!
}
// realtime_table 조회가 전혀 없음
영향: System Alarm 감지가 영구 비활성화 상태. ALARM/NORMAL/CHANGE 이벤트가 단 하나도 기록되지 않음.
수정: LoadSystemAlarmStatesAsync에서 config 조회 후 realtime_table과 JOIN하여 현재값 채움:
private async Task LoadSystemAlarmStatesAsync(IExperionDbService db)
{
_systemStates.Clear();
var configs = await db.GetSystemAlarmConfigsAsync();
var points = await db.GetSystemAlarmPointsAsync();
var pointLookup = points.ToDictionary(p => p.NodeId, p => p.LiveValue);
var groups = configs.GroupBy(c => c.InstanceName);
foreach (var g in groups)
{
var values = new Dictionary<string, string?>();
foreach (var cfg in g)
if (pointLookup.TryGetValue(cfg.NodeId, out var v))
values[cfg.AttrName] = v;
_systemStates[g.Key] = new SystemAlarmState(values, DateTime.UtcNow, null);
}
}
[2]. RefreshPvCacheAsync — 전체 realtime_table 메모리 적재 (MED)
문제: GetRealtimePointsAsync()로 realtime_table 전체(957건)를 메모리에 적재한 후 C#에서 .EndsWith(".pv")로 필터링. 957건 중 685건만 필요하지만 전체 테이블을 조회하는 비효율.
근거: 엑스페리온-전체알람-추가플랜.md:775-786 (D2 코드 템플릿)
var pvPoints = await db.GetRealtimeRecordsByTagNamesAsync(
(await db.GetRealtimePointsAsync()) // ← 전체 957건 적재
.Where(p => p.TagName != null && p.TagName.EndsWith(".pv"))
.Select(p => p.TagName!)
.ToList()
);
영향: 957건 전체가 메모리에 올라감 + 2회 DB 쿼리. Point Alarm 394개만 필요하므로 불필요한 오버헤드.
수정: GetPvPointsAsync() 전용 메서드를 IExperionDbService에 추가하거나, GetRealtimeRecordsByTagNamesAsync에 직접 LIKE 패턴 전달:
// IExperionDbService에 추가
Task<IEnumerable<RealtimePoint>> GetPvPointsAsync();
// 구현
public async Task<IEnumerable<RealtimePoint>> GetPvPointsAsync()
{
return await _ctx.RealtimePoints
.Where(p => p.TagName != null && p.TagName.EndsWith(".pv"))
.ToListAsync();
}
[3]. DeterminePointEventType — unackn 상태 전이 로직 불완전 (MED)
문제: DeterminePointEventType에서 isUnackn일 때 prevBool && !currBool → CHANGE만 반환. 하지만 unackn이 0→1로 변하는 경우(미확인 알람 발생)를 처리하지 않음. 또한 inalm/unackn을 개별 태그로 분리해서 보는데, 실제 Experion에서는 같은 포인트의 inalm과 unackn이 함께 동작하므로 동시 조회 + 조합 판단이 필요.
근거: 엑스페리온-전체알람-추가플랜.md:866-881 (D2 코드 템플릿)
else if (isUnackn)
{
if (prevBool && !currBool) return "CHANGE"; // 1→0: ack만 처리, 0→1 미처리
}
영향: unackn이 0→1로 변하는 미확인 알람 발생 이벤트를 감지하지 못함. 또한 inalm과 unackn을 개별 루프에서 독립적으로 판단하므로 두 태그의 조합(예: inalm=1 + unackn=0 = 알람 해제)을 올바르게 해석하지 못함.
수정: inalm과 unackn을 같은 base tag로 그룹핑하여 조합 판단:
// PointAlarmState를 base tag 기준으로 관리
private readonly ConcurrentDictionary<string, (string? inalm, string? unackn, DateTime Timestamp, string? EventType)> _pointStates = new();
private string? DeterminePointEventType(string baseTag, string? newInalm, string? newUnackn)
{
var prev = _pointStates.GetValueOrDefault(baseTag);
bool prevInalm = prev.inalm == "True" || prev.inalm == "1";
bool currInalm = newInalm == "True" || newInalm == "1";
bool prevUnackn = prev.unackn == "True" || prev.unackn == "1";
bool currUnackn = newUnackn == "True" || newUnackn == "1";
if (!prevInalm && currInalm) return "ALARM"; // inalm 0→1
if (prevInalm && !currInalm && !currUnackn) return "NORMAL"; // inalm 1→0 + unackn 0
if (prevInalm && prevUnackn && !currUnackn) return "CHANGE"; // unackn 1→0 (ACK)
return null;
}
[4]. E1 Scan 엔드포인트 — node_map_master 기반 탐색의 한계 (LOW)
문제: Scan이 QueryMasterAsync(names: ["status"], dataType: "i=7594")로 node_map_master를 조회하여 system alarm 인스턴스를 발견하도록 설계됨. 하지만 node_map_master는 PointBuilder가 구축할 때 생성되며, $systemalarmgroupmodel 하위 인스턴스는 level=3으로 저장되지 않을 수 있음(모델 변수는 level이 다를 수 있음).
근거: 엑스페리온-전체알람-추가플랜.md:1020-1030 (E1 코드 템플릿)
var instancePrefixes = await _dbSvc.QueryMasterAsync(
minLevel: null, maxLevel: null, nodeClass: null,
names: new[] { "status" }, nodeId: null, dataType: "i=7594",
limit: 10000, offset: 0);
영향: node_map_master에 system alarm 인스턴스가 포함되어 있지 않으면 scan이 0건을 반환. PointBuilder(/api/pointbuilder/build)가 system alarm 노드를 node_map_master에 포함시키지 않도록 설계되어 있을 가능성 있음.
수정: Scan 시 node_map_master를 1차 필터로 사용하되, 결과가 0건이면 OPC UA Browse로 직접 탐색하는 폴백 경로를 추가. 또는 POST /api/system-alarms/scan이 Browse를 직접 수행하도록 변경 (plan 2.2의 "OPC UA Browse로 system alarm 인스턴스 탐색" 의도와 일치).
[5]. E3 Clear 엔드포인트 — O(n*m) 성능 문제 (LOW)
문제: ClearSystemAlarms에서 GetRealtimePointsAsync()로 전체 realtime_table을 로드한 후 FirstOrDefault(p => p.NodeId == nid)로 순차 검색. nodeIds가 100개면 100 × 957 = 95,700回の 비교가 메모리에서 발생.
근거: 엑스페리온-전체알람-추가플랜.md:1171-1181 (E3 코드 템플릿)
var nodeIds = configs.Select(c => c.NodeId).ToHashSet();
foreach (var nid in nodeIds)
{
var pt = (await _dbSvc.GetRealtimePointsAsync()) // ← 전체 로드
.FirstOrDefault(p => p.NodeId == nid); // ← 순차 검색
if (pt != null)
await _dbSvc.DeleteRealtimePointAsync(pt.Id);
}
영향: 실시간 테이블이 커질수록 clear 성능이 선형적으로 저하. 10,000개 포인트에서 수 초 이상 소요 가능.
수정: DB에서 직접 DELETE (WHERE node_id IN (...)) 또는 GetRealtimePointsByNodeIdsAsync 메서드 추가:
// IExperionDbService에 추가
Task<int> DeleteRealtimePointsByNodeIdsAsync(IEnumerable<string> nodeIds);
// 구현: WHERE node_id IN (...) 한 번의 쿼리로 삭제
[6]. D2 _pvCache — 최초 로드 후 갱신 없음 (LOW)
문제: _pvCache는 ExecuteAsync 시작 시 한 번만 RefreshPvCacheAsync()로 로드됨. 이후 감지 루프에서 갱신하지 않으므로, alarm 이벤트 발생 시 .pv 현재값이 stale할 수 있음.
근거: 엑스페리온-전체알람-추가플랜.md:732-743 (D2 ExecuteAsync)
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await LoadPointAlarmStatesAsync(db);
await LoadSystemAlarmStatesAsync(db);
await RefreshPvCacheAsync(db); // ← 최초 1회만
}
// 이후 루프에서 갱신 없음
영향: Alarm 발생 시 기록되는 curr_value가 실제 현재 PV와 다를 수 있음. ExperionRealtimeService의 FlushLoop가 realtime_table을 계속 업데이트하므로, 감지 루프에서도 주기적으로 PV 캐시를 갱신해야 정확함.
수정: 감지 루프마다 _pvCache를 갱신하거나, DetectPointAlarmChangesAsync 내에서 개별 base tag의 .pv 값을 실시간으로 조회:
// 루프 내부에 추가
if (loopCount % 10 == 0) // 10초마다 갱신
await RefreshPvCacheAsync(db);
| 번호 | 항목 | 심각도 | 교차검증 |
|---|---|---|---|
| [1] | LoadSystemAlarmStatesAsync 상태 미초기화 | HIGH | Q1-아니오 Q2-아니오 Q3-아니오 Q4-예(영구 비활성화) |
| [2] | RefreshPvCacheAsync 비효율 | MED | Q1-아니오 Q2-아니오 Q3-아니오 Q4-예(성능 저하) |
| [3] | DeterminePointEventType 로직 불완전 | MED | Q1-아니오 Q2-아니오 Q3-아니오 Q4-예(미확인 알람 누락) |
| [4] | Scan 엔드포인트 탐색 한계 | LOW | Q1-아니오 Q2-아니오 Q3-의도적이지 않음 Q4-예(0건 리턴) |
| [5] | Clear 엔드포인트 성능 | LOW | Q1-아니오 Q2-아니오 Q3-아니오 Q4-예(대규모 테이블에서 지연) |
| [6] | _pvCache 갱신 없음 | LOW | Q1-아니오 Q2-아니오 Q3-아니오 Q4-예(stale 데이터) |
1. 현황 분석
1.1 현재 OPC UA Browse 현황 (node_map_master)
| 항목 | 개수 |
|---|---|
| 전체 노드 | 530,080 |
.inalm (point-level) |
707 |
.unackn (point-level) |
709 |
.almsts (point-level) |
706 |
.pvloalm, .pvhialm (analog limit) |
각 265 |
$systemalarmgroupmodel 모델 변수 |
185 |
System alarm 인스턴스 (status+pointinalarm+totalactivealarms) |
22 |
1.2 현재 실시간 모니터링 현황 (realtime_table)
| 항목 | 개수 |
|---|---|
| 전체 | 957 |
.pv |
685 |
.inalm / .unackn |
0 (전혀 없음) |
| System alarm 노드 | 0 (전혀 없음) |
1.3 문제점
.pv값 문자열 기반 ({5 | R-TRIP | }등)으로만 이벤트 감지 → 아날로그 한계 알람 미감지- 시스템 레벨 (서버/채널/컨트롤러) 알람 상태 전혀 미감지
.inalm/.unackn→ 실시간 구독 없음 → DB에도 없음
2. 전체 아키텍처
2.1 데이터 구분
| 구분 | 대상 | 소스 | 이벤트 저장소 |
|---|---|---|---|
| Point Alarm | sinamserver:xxx |
.inalm false↔true + .unackn + .pv |
event_history_table |
| System Alarm | $srvsinamserver, $channel*, $controller* 등 |
pointinalarm, status, unackalarmexists |
system_alarm_history_table |
2.2 데이터 흐름
┌──────────────────────────────┐
│ OPC UA Server (Honeywell) │
│ sinamserver:xxx.pv │
│ sinamserver:xxx.inalm │
│ sinamserver:xxx.unackn │
│ $srvsinamserver.status │
│ $channel0001.pointinalarm │
│ $controller0001.status │
│ ... │
└──────────┬───────────────────┘
│
RealtimeService (Subscription + 500ms flush)
│
┌──────────▼───────────────────┐
│ realtime_table │
│ (.pv + .inalm + .unackn │
│ + system_alarm node_ids) │
└────┬──────┬──────────────────┘
│ │
poll 1s │ │ poll 1s
│ │
┌──────────▼┐ ┌──▼──────────────────┐
│DigitalEvent │ │ AlarmDetectorService │
│Detector │ │ (신규) │
│Service │ │ │
│(기존 .pv) │ │ inalm/unackn 감지 │
│ │ │ system_alarm 감지 │
└──────┬─────┘ └──────┬──────────────┘
│ │
┌─────────▼─────┐ ┌──────▼──────────────────┐
│event_history │ │system_alarm_history │
│_table │ │_table │
│(.pv 문자열 │ │(system 상태 변화 기록) │
│ 변화 기록) │ │ │
└───────────────┘ └──────────────────────────┘
3. Point Alarm — .inalm/.unackn 기반 알람 감지
3.1 DB 변경
realtime_table 에 .inalm/.unackn 노드 추가 (Approach B)
3.2 이벤트 감지 로직 (AlarmDetectorService)
| prev inalm | curr inalm | prev unackn | curr unackn | event_type | curr_value 저장 |
|---|---|---|---|---|---|
| false/0 | true/1 | * | * | ALARM |
.pv 현재값 |
| true/1 | false/0 | * | 0 | NORMAL |
.pv 현재값 |
| true/1 | true/1 | true/1 | false/0 | CHANGE (ACK) |
.pv 현재값 |
duration_seconds: 직전 상태 유지 시간 (기존 convention 유지)TagName: base tag (.inalm/.unackn제거한 이름)- 저장 테이블:
event_history_table(기존, category 구분 없이)
3.3 적용 범위
기존 .pv base tag 중 node_map_master에 .inalm이 존재하는 394개 포인트
4. System Alarm — 서버/채널/컨트롤러 알람 감지
4.1 System Alarm 타입 식별
$systemalarmgroupmodel 타입 인스턴스는 다음 시그니처로 식별:
status(data_type = i=7594)pointinalarm(data_type = Int32)unackalarmexists(data_type = Int32)totalactivealarms(data_type = Int32)
이를 만족하는 OPC UA 오브젝트를 동적 탐색하여 system_alarm_config에 저장.
4.2 System Alarm Instance 타입 분류
| instance_type | 판별 기준 | 예 |
|---|---|---|
server |
numberofparents = 0, node_id가 $srv prefix |
$srvsinamserver |
channel |
node_id에 $channel 포함 |
$channel0001 (chauni0) |
controller |
node_id에 $controller 포함 |
$controller0001 (c1) |
group |
그 외 (집계 그룹) | $stations, $consoles |
4.3 인스턴스별 모니터링 속성
server
status, primarystatus, backupstatus, backupsyncstatus,
link1status, link2status, datastatus, notificationstatus,
dsalink0state, dsalink1state, dsalink2state, dsalink3state,
pointinalarm, unackalarmexists, totalactivealarms
channel
status, pointinalarm, unackalarmexists, totalactivealarms,
linkastatus, linkbstatus, connection,
packetcount, packetrate, lnkapercenterrors, lnkabarcount,
utilization
controller
status, pointinalarm, unackalarmexists, totalactivealarms,
linkastatus, linkbstatus, connection,
packetcount, packetrate, lnkapercenterrors, lnkabarcount,
errorcode
group
status, pointinalarm, unackalarmexists, totalactivealarms
4.4 이벤트 감지 로직
| prev pointinalarm | curr pointinalarm | prev status | curr status | event_type |
|---|---|---|---|---|
| 0 | >0 | * | * | ALARM |
| >0 | 0 | * | * | NORMAL |
| * | * | GOOD | BAD/FAIL | CHANGE (diagnostic) |
| * | * | BAD/FAIL | GOOD | NORMAL |
curr_value: 전체 상태 스냅샷 JSON ({pointinalarm: 1, status: "GOOD", totalactivealarms: 3})duration_seconds: 직전 상태 유지 시간
5. 신규 DB 테이블
system_alarm_config
CREATE TABLE IF NOT EXISTS system_alarm_config (
id SERIAL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_type TEXT NOT NULL, -- 'server' | 'channel' | 'controller' | 'group'
attr_name TEXT NOT NULL,
node_id TEXT NOT NULL UNIQUE,
tagname TEXT NOT NULL,
display_order INT DEFAULT 0
);
system_alarm_history_table
CREATE TABLE IF NOT EXISTS system_alarm_history_table (
id BIGSERIAL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_type TEXT NOT NULL,
attr_name TEXT NOT NULL,
prev_value TEXT,
curr_value TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL, -- 'ALARM' | 'NORMAL' | 'CHANGE'
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
duration_seconds INT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sysalarm_instance_time
ON system_alarm_history_table(instance_name, event_time DESC);
CREATE INDEX idx_sysalarm_type_time
ON system_alarm_history_table(instance_type, event_time DESC);
CREATE INDEX idx_sysalarm_event_type
ON system_alarm_history_table(event_type, event_time DESC);
6. 초기화 API
POST /api/system-alarms/scan
→ OPC UA Browse로 system alarm 인스턴스 탐색
→ system_alarm_config에 INSERT
→ 각 node_id를 realtime_table에 APPEND
→ RealtimeService.AddMonitoredItemAsync hot-add
Response: { success: true, instances: [...], added: N }
POST /api/system-alarms/configure
→ system_alarm_config 조회
→ realtime_table에 없는 node_id만 APPEND
→ hot-add
Response: { success: true, added: N }
DELETE /api/system-alarms/clear
→ system_alarm_config TRUNCATE
→ realtime_table에서 관련 항목 삭제
Response: { success: true, removed: N }
7. 파일 변경 목록
| 파일 | 변경 | 내용 |
|---|---|---|
src/Core/Domain/Entities/ExperionEntities.cs |
+2 Entity | SystemAlarmConfig, SystemAlarmHistoryRecord |
src/Core/Application/Interfaces/IExperionServices.cs |
+메서드 | System alarm CRUD + scan 인터페이스 |
src/Infrastructure/Database/ExperionDbContext.cs |
+DDL + 메서드 | 테이블 생성, AppendAlarmNodesAsync, ScanSystemAlarmsAsync, Config CRUD |
src/Infrastructure/OpcUa/AlarmDetectorService.cs |
신규 | inalm/unackn 감지 + system alarm 감지 (1s poll) |
src/Web/Program.cs |
+1줄 | AddHostedService<AlarmDetectorService>() |
src/Web/Controllers/ExperionControllers.cs |
+API | /api/system-alarms/* 3개 엔드포인트 |
8. 결정사항
| 결정 | 내용 |
|---|---|
| inalm/unackn 기반 Point Alarm | DigitalEventDetectorService는 .pv 문자열 기반 유지. AlarmDetectorService에서 inalm/unackn 감지 별도 수행 |
| System Alarm 저장소 | system_alarm_history_table 별도 테이블 (기존 event_history_table과 분리) |
| OPC UA 접근 | RealtimeService subscription 기존 파이프라인 재사용. 새 subscription 생성 안 함 |
| 핫 추가 | AddMonitoredItemAsync + ApplyChanges로 runtime 중단 없이 추가 |
| 이벤트 구분 | point alarm → .pv 현재값 저장. system alarm → JSON 상태 스냅샷 저장 |
| 초기화 | 수동 트리거 (버튼 → POST /api/system-alarms/scan). 자동은 아님 |
9. Implementation Todo / CheckList
각 항목은 완료 시
[ ]→[x]로 변경. 구현 순서대로 정렬됨.
Phase A — Domain & Entity
- A1
src/Core/Domain/Entities/ExperionEntities.cs—SystemAlarmConfig엔티티 추가- 필드:
Id,InstanceName,InstanceType,AttrName,NodeId,TagName,DisplayOrder - Table:
system_alarm_config
- 필드:
- A2
src/Core/Domain/Entities/ExperionEntities.cs—SystemAlarmHistoryRecord엔티티 추가- 필드:
Id,InstanceName,InstanceType,AttrName,PrevValue,CurrValue,EventType,EventTime,DurationSeconds,Metadata,CreatedAt - Table:
system_alarm_history_table
- 필드:
Phase B — Interface
- B1
src/Core/Application/Interfaces/IExperionServices.cs—IExperionDbService에 메서드 추가:Task<int> AppendPointAlarmNodesAsync()— realtime_table에.inalm/.unackn노드 추가Task<List<SystemAlarmConfig>> GetSystemAlarmConfigsAsync()Task<int> SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig> configs)Task<int> ClearSystemAlarmConfigsAsync()Task<int> BatchRecordSystemAlarmEventsAsync(IEnumerable<DigitalEventRecord> records)Task<IEnumerable<RealtimePoint>> GetSystemAlarmPointsAsync()— system_alarm_config 관련 realtime_table 조회Task<IEnumerable<RealtimePoint>> GetPointAlarmPointsAsync()— inalm/unackn 관련 realtime_table 조회
Phase C — Database Layer
- C1
src/Infrastructure/Database/ExperionDbContext.cs—DbSet<SystemAlarmConfig>,DbSet<SystemAlarmHistoryRecord>추가 - C2
src/Infrastructure/Database/ExperionDbContext.cs— DDL:system_alarm_configCREATE TABLE IF NOT EXISTS (기존EnsureDatabaseAsync에 추가) - C3
src/Infrastructure/Database/ExperionDbContext.cs— DDL:system_alarm_history_tableCREATE TABLE IF NOT EXISTS + indexes - C4
src/Infrastructure/Database/ExperionDbContext.cs—AppendPointAlarmNodesAsync()구현- node_map_master에서
level=3AND (name='inalm'ORname='unackn') 조회 - 기존 realtime_table
.pvbase tag와 JOIN하여 대응되는 alarm 노드만 필터 - 아직 realtime_table에 없는 node_id만 INSERT (기존
AppendPointsAsync패턴) - 반환값: 추가된 개수
- node_map_master에서
- C5
src/Infrastructure/Database/ExperionDbContext.cs—GetSystemAlarmConfigsAsync()구현 - C6
src/Infrastructure/Database/ExperionDbContext.cs—SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig>)구현 (upsert 패턴) - C7
src/Infrastructure/Database/ExperionDbContext.cs—ClearSystemAlarmConfigsAsync()구현 (TRUNCATE or DELETE) - C8
src/Infrastructure/Database/ExperionDbContext.cs—BatchRecordSystemAlarmEventsAsync(IEnumerable<DigitalEventRecord>)구현 - C9
src/Infrastructure/Database/ExperionDbContext.cs—GetSystemAlarmPointsAsync()구현system_alarm_config와realtime_table을node_id로 JOIN하여 현재값 조회
- C10
src/Infrastructure/Database/ExperionDbContext.cs—GetPointAlarmPointsAsync()구현realtime_table에서tagname LIKE '%.inalm'ORtagname LIKE '%.unackn'조회
Phase D — AlarmDetectorService (Core)
-
D1
src/Infrastructure/OpcUa/AlarmDetectorService.cs— 신규 파일 생성BackgroundService상속- DI:
IServiceScopeFactory,ILogger<AlarmDetectorService> _checkIntervalMs = 1000(1초)- 내부 상태:
ConcurrentDictionary<string, AlarmPointState>for inalm/unackn - 내부 상태:
ConcurrentDictionary<string, SystemAlarmState>for system alarms
-
D2
src/Infrastructure/OpcUa/AlarmDetectorService.cs— Point Alarm 감지 (inalm/unackn)LoadPointAlarmStatesAsync()— 시작 시 현재값 로드- 1초 루프에서
GetPointAlarmPointsAsync()호출 - 상태 전이:
- inalm 0→1:
ALARM(curr_value =.pv값, 기존 DigitalEventDetectorService가 기록한 latest .pv 조회) - inalm 1→0 + unackn 0:
NORMAL - unackn 1→0 (inalm=1):
CHANGE(ACK, metadata에 포함)
- inalm 0→1:
DigitalEventRecord생성 →BatchRecordDigitalEventsAsync(기존 메서드) 호출_debounceSeconds = 5(기존과 동일)
-
D3
src/Infrastructure/OpcUa/AlarmDetectorService.cs— System Alarm 감지LoadSystemAlarmStatesAsync()— 시작 시GetSystemAlarmPointsAsync()로 현재값 로드- 상태 머신 (instance 단위로 그룹핑):
pointinalarm: 0→>0 =ALARM, >0→0 =NORMALstatus: GOOD→BAD =CHANGE(diagnostic), BAD→GOOD =NORMAL
curr_value= 전체 속성 스냅샷 JSON ({pointinalarm, status, totalactivealarms, ...})SystemAlarmEventRecord생성 →BatchRecordSystemAlarmEventsAsync호출
-
D4
src/Infrastructure/OpcUa/AlarmDetectorService.cs— 루프 구조while (!stoppingToken) wait 1s if connected: await DetectPointAlarmChangesAsync() await DetectSystemAlarmChangesAsync() catch → log error, continue
Phase E — Web / API
-
E1
src/Web/Controllers/ExperionControllers.cs—POST /api/system-alarms/scan엔드포인트IServiceScopeFactory로 OPC UA client 생성- OPC UA Browse: $systemalarmgroupmodel 타입 인스턴스 탐색
- Browse Objects 폴더(i=85) → $systemalarmgroupmodel 하위 인스턴스 탐색
- 또는 이미 node_map_master에서
status:i=7594+pointinalarm+totalactivealarms시그니처로 탐색
- 발견된 인스턴스 → instance_type 분류 → system_alarm_config에 저장
- 각 config의 node_id → realtime_table에 append
- RealtimeService.AddMonitoredItemAsync 호출 (hot-add)
- 응답:
{ success, count, instances: [...] }
-
E2
src/Web/Controllers/ExperionControllers.cs—POST /api/system-alarms/configure엔드포인트- system_alarm_config 조회 → realtime_table에 없는 node_id만 append + hot-add
-
E3
src/Web/Controllers/ExperionControllers.cs—DELETE /api/system-alarms/clear엔드포인트- system_alarm_config TRUNCATE + realtime_table 관련 항목 삭제
-
E4
src/Web/Controllers/ExperionControllers.cs—GET /api/system-alarms/config엔드포인트 (조회용) -
E5
src/Web/Controllers/ExperionControllers.cs—POST /api/system-alarms/append-point-alarms엔드포인트AppendPointAlarmNodesAsync()호출 + hot-add
Phase F — Registration
- F1
src/Web/Program.cs—builder.Services.AddHostedService<AlarmDetectorService>()추가
Phase G — Verify
- G1
dotnet build— 컴파일 성공 확인 - G2
dotnet test— 기존 테스트 통과 확인 - G3 서비스 동작 확인 (런타임):
POST /api/system-alarms/scan→ system_alarm_config에 데이터 저장 확인GET /api/system-alarms/config→ 저장된 config 반환 확인- realtime_table에 system alarm / point alarm node_id 추가 확인
- OPC UA 서버에 실제로 subscribe 되었는지 확인 (subscription count 증가)
- alarm 값 변화 시 system_alarm_history_table에 이벤트 기록 확인
Phase H — 기존 코드 영향도
- H1
DigitalEventDetectorService변경 없음. 기존.pv문자열 기반 감지 유지됨을 확인 - H2
ExperionRealtimeService.ConnectAndSubscribeAsync()— realtime_table 다시 읽기만 하면 새 노드 자동 구독 - H3
node_map_master업데이트 시 / api/pointbuilder/build 후에도 system_alarm_config 유지 확인 (독립 테이블이므로 영향 없음)
10. Implementation Code Templates
아래 코드 템플릿은 각 Phase의 실제 구현 코드입니다. 복사하여 해당 파일에 삽입하세요. 각 코드 블록은
// >>> [파일명]마커로 시작하여// <<< [파일명]으로 끝납니다.
Phase A — Entity 클래스 (src/Core/Domain/Entities/ExperionEntities.cs)
EventHistoryRecord 클래스 바로 앞에 다음 2개 클래스를 추가:
// >>> A1: SystemAlarmConfig entity
/// <summary>system_alarm_config — 시스템 알람 구성 (OPC UA system alarm group 인스턴스)</summary>
[Table("system_alarm_config")]
public class SystemAlarmConfig
{
[Key]
[Column("id")] public int Id { get; set; }
[Column("instance_name")] public string InstanceName { get; set; } = string.Empty;
[Column("instance_type")] public string InstanceType { get; set; } = string.Empty; // server / channel / controller / group
[Column("attr_name")] public string AttrName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("display_order")] public int DisplayOrder { get; set; }
}
// <<< A1
// >>> A2: SystemAlarmHistoryRecord entity
/// <summary>system_alarm_history_table — 시스템 알람 상태 변경 이벤트</summary>
[Table("system_alarm_history_table")]
public class SystemAlarmHistoryRecord
{
[Key]
[Column("id")] public long Id { get; set; }
[Column("instance_name")] public string InstanceName { get; set; } = string.Empty;
[Column("instance_type")] public string InstanceType { get; set; } = string.Empty;
[Column("attr_name")] public string AttrName { get; set; } = string.Empty;
[Column("prev_value")] public string? PrevValue { get; set; }
[Column("curr_value")] public string CurrValue { get; set; } = string.Empty;
[Column("event_type")] public string EventType { get; set; } = string.Empty; // ALARM / NORMAL / CHANGE
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
[Column("metadata")] public string? Metadata { get; set; }
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// <<< A2
Phase B — Interface 메서드 (src/Core/Application/Interfaces/IExperionServices.cs)
IExperionDbService 인터페이스 내 QueryEventHistoryAsync 메서드 바로 다음에 추가:
// ── System Alarm (신규) ──────────────────────────────────────────────────
/// <summary>node_map_master에서 .inalm/.unackn 노드를 realtime_table에 추가</summary>
Task<int> AppendPointAlarmNodesAsync();
/// <summary>system_alarm_config 전체 조회</summary>
Task<List<SystemAlarmConfig>> GetSystemAlarmConfigsAsync();
/// <summary>system_alarm_config 저장 (upsert)</summary>
Task<int> SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig> configs);
/// <summary>system_alarm_config 전체 삭제</summary>
Task<int> ClearSystemAlarmConfigsAsync();
/// <summary>system_alarm_history_table에 배치 기록</summary>
Task<int> BatchRecordSystemAlarmEventsAsync(IEnumerable<SystemAlarmEventRecord> records);
/// <summary>system_alarm_config 관련 realtime_table 현재값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetSystemAlarmPointsAsync();
/// <summary>realtime_table에서 inalm/unackn 현재값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetPointAlarmPointsAsync();
같은 파일 하단, DigitalEventRecord 클래스 다음에 추가:
// >>> SystemAlarmEventRecord DTO
/// <summary>시스템 알람 이벤트 기록용 DTO</summary>
public class SystemAlarmEventRecord
{
public string InstanceName { get; set; } = "";
public string InstanceType { get; set; } = "";
public string AttrName { get; set; } = "";
public string? PrevValue { get; set; }
public string CurrValue { get; set; } = "";
public string EventType { get; set; } = ""; // ALARM / NORMAL / CHANGE
public DateTime EventTime { get; set; }
public int? DurationSeconds { get; set; }
public string? Metadata { get; set; }
}
// <<< SystemAlarmEventRecord DTO
/// <summary>이벤트 히스토리 조회 결과 행</summary>
Phase C — DbContext (src/Infrastructure/Database/ExperionDbContext.cs)
C1 — OnModelCreating에 추가
// >>> C1: SystemAlarmConfig + SystemAlarmHistoryRecord model config
modelBuilder.Entity<SystemAlarmConfig>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.NodeId).IsUnique();
e.HasIndex(x => new { x.InstanceName, x.AttrName });
});
modelBuilder.Entity<SystemAlarmHistoryRecord>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.EventTime);
e.HasIndex(x => new { x.InstanceName, x.EventTime }).HasDatabaseName("idx_sysalarm_instance_time");
e.HasIndex(x => new { x.InstanceType, x.EventTime }).HasDatabaseName("idx_sysalarm_type_time");
e.HasIndex(x => x.EventType).HasDatabaseName("idx_sysalarm_event_type");
e.Property(x => x.Metadata).HasColumnType("jsonb");
});
// <<< C1
C2 — EnsureDatabaseAsync DDL에 추가 (event_history_table 다음)
// >>> C2: system_alarm_config DDL
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS system_alarm_config (
id SERIAL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_type TEXT NOT NULL,
attr_name TEXT NOT NULL,
node_id TEXT NOT NULL UNIQUE,
tagname TEXT NOT NULL,
display_order INT DEFAULT 0
)
""");
// <<< C2
// >>> C3: system_alarm_history_table DDL + indexes
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS system_alarm_history_table (
id BIGSERIAL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_type TEXT NOT NULL,
attr_name TEXT NOT NULL,
prev_value TEXT,
curr_value TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
duration_seconds INT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_sysalarm_instance_time
ON system_alarm_history_table(instance_name, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_sysalarm_type_time
ON system_alarm_history_table(instance_type, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_sysalarm_event_type
ON system_alarm_history_table(event_type, event_time DESC);
""");
// <<< C3
C4 — AppendPointAlarmNodesAsync (기존 AppendPointsAsync 근처에 추가)
// >>> C4: AppendPointAlarmNodesAsync
public async Task<int> AppendPointAlarmNodesAsync()
{
// node_map_master에서 inalm/unackn 노드 조회 (sinamserver point-level)
var alarmNodes = await _ctx.NodeMapMasters
.Where(n => n.Level == 3 && (n.Name == "inalm" || n.Name == "unackn"))
.Select(n => n.NodeId)
.ToListAsync();
// realtime_table에 이미 있는 node_id 제외
var existing = await _ctx.RealtimePoints
.Where(p => alarmNodes.Contains(p.NodeId))
.Select(p => p.NodeId)
.ToListAsync();
var existingSet = new HashSet<string>(existing);
var newNodes = alarmNodes.Where(n => !existingSet.Contains(n)).ToList();
if (newNodes.Count == 0) return 0;
var points = newNodes.Select(nodeId => new RealtimePoint
{
TagName = ExtractTagName(nodeId),
NodeId = nodeId,
LiveValue = null,
Timestamp = DateTime.UtcNow
}).ToList();
await _ctx.RealtimePoints.AddRangeAsync(points);
var saved = await _ctx.SaveChangesAsync();
_logger.LogInformation("[ExperionDb] Point alarm 노드 추가: {Count}건", saved);
return saved;
}
// <<< C4
C5 — GetPointAlarmPointsAsync
// >>> C5: GetPointAlarmPointsAsync
public async Task<IEnumerable<RealtimePoint>> GetPointAlarmPointsAsync()
{
return await _ctx.RealtimePoints
.Where(p => p.TagName != null &&
(p.TagName.EndsWith(".inalm") || p.TagName.EndsWith(".unackn")))
.ToListAsync();
}
// <<< C5
C6 — System alarm config CRUD
// >>> C6: GetSystemAlarmConfigsAsync
public async Task<List<SystemAlarmConfig>> GetSystemAlarmConfigsAsync()
{
return await _ctx.Set<SystemAlarmConfig>()
.OrderBy(c => c.InstanceName)
.ThenBy(c => c.DisplayOrder)
.ToListAsync();
}
// <<< C6
// >>> C7: SaveSystemAlarmConfigsAsync
public async Task<int> SaveSystemAlarmConfigsAsync(List<SystemAlarmConfig> configs)
{
_ctx.Set<SystemAlarmConfig>().AddRange(configs);
return await _ctx.SaveChangesAsync();
}
// <<< C7
// >>> C8: ClearSystemAlarmConfigsAsync
public async Task<int> ClearSystemAlarmConfigsAsync()
{
return await _ctx.Database.ExecuteSqlRawAsync("TRUNCATE TABLE system_alarm_config RESTART IDENTITY");
}
// <<< C8
C9 — GetSystemAlarmPointsAsync
// >>> C9: GetSystemAlarmPointsAsync
public async Task<IEnumerable<RealtimePoint>> GetSystemAlarmPointsAsync()
{
var configs = await _ctx.Set<SystemAlarmConfig>().Select(c => c.NodeId).ToListAsync();
if (configs.Count == 0) return Enumerable.Empty<RealtimePoint>();
return await _ctx.RealtimePoints
.Where(p => configs.Contains(p.NodeId))
.ToListAsync();
}
// <<< C9
C10 — BatchRecordSystemAlarmEventsAsync
// >>> C10: BatchRecordSystemAlarmEventsAsync
public async Task<int> BatchRecordSystemAlarmEventsAsync(IEnumerable<SystemAlarmEventRecord> records)
{
var rows = records.Select(r => new SystemAlarmHistoryRecord
{
InstanceName = r.InstanceName,
InstanceType = r.InstanceType,
AttrName = r.AttrName,
PrevValue = r.PrevValue,
CurrValue = r.CurrValue,
EventType = r.EventType,
EventTime = r.EventTime,
DurationSeconds = r.DurationSeconds,
Metadata = r.Metadata
}).ToList();
await _ctx.Set<SystemAlarmHistoryRecord>().AddRangeAsync(rows);
return await _ctx.SaveChangesAsync();
}
// <<< C10
Phase D — AlarmDetectorService (src/Infrastructure/OpcUa/AlarmDetectorService.cs)
신규 파일, 전체 내용:
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.Json;
namespace ExperionCrawler.Infrastructure.OpcUa;
// >>> D1: 내부 상태 기록
internal record PointAlarmState(string? Value, DateTime Timestamp, string? EventType);
internal record SystemAlarmState(Dictionary<string, string?> Values, DateTime Timestamp, string? LastEventType);
// <<< D1
// >>> D2: AlarmDetectorService
public class AlarmDetectorService : Microsoft.Extensions.Hosting.BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AlarmDetectorService> _logger;
private readonly int _checkIntervalMs = 1000;
private readonly int _debounceSeconds = 5;
// Point alarm states (inalm/unackn)
private readonly ConcurrentDictionary<string, PointAlarmState> _pointStates = new();
// System alarm states (grouped by instance_name)
private readonly ConcurrentDictionary<string, SystemAlarmState> _systemStates = new();
// Cached PV values for point alarm curr_value
private readonly ConcurrentDictionary<string, string?> _pvCache = new();
private static readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AlarmDetectorService(IServiceScopeFactory scopeFactory, ILogger<AlarmDetectorService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[AlarmDetector] 시작");
// Load initial states
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await LoadPointAlarmStatesAsync(db);
await LoadSystemAlarmStatesAsync(db);
await RefreshPvCacheAsync(db);
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_checkIntervalMs, stoppingToken);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await DetectPointAlarmChangesAsync(db, stoppingToken);
await DetectSystemAlarmChangesAsync(db, stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "[AlarmDetector] 감지 루프 오류");
}
}
}
// <<< D2
// >>> D3: Point Alarm 감지
private async Task LoadPointAlarmStatesAsync(IExperionDbService db)
{
_pointStates.Clear();
var points = await db.GetPointAlarmPointsAsync();
foreach (var p in points)
_pointStates[p.TagName] = new PointAlarmState(p.LiveValue, p.Timestamp, null);
_logger.LogDebug("[AlarmDetector] Point alarm 상태 로드: {Count}개", _pointStates.Count);
}
private async Task RefreshPvCacheAsync(IExperionDbService db)
{
_pvCache.Clear();
var pvPoints = await db.GetRealtimeRecordsByTagNamesAsync(
(await db.GetRealtimePointsAsync())
.Where(p => p.TagName != null && p.TagName.EndsWith(".pv"))
.Select(p => p.TagName!)
.ToList()
);
foreach (var p in pvPoints)
_pvCache[p.TagName] = p.LiveValue;
}
private async Task DetectPointAlarmChangesAsync(IExperionDbService db, CancellationToken ct)
{
var currentPoints = await db.GetPointAlarmPointsAsync();
if (ct.IsCancellationRequested) return;
var events = new List<DigitalEventRecord>();
foreach (var point in currentPoints)
{
if (point.TagName == null) continue;
var prevState = _pointStates.GetValueOrDefault(point.TagName);
var currValue = point.LiveValue ?? "";
if (prevState == null)
{
_pointStates[point.TagName] = new PointAlarmState(currValue, DateTime.UtcNow, null);
continue;
}
if (prevState.Value != currValue)
{
var now = DateTime.UtcNow;
var elapsed = (now - prevState.Timestamp).TotalSeconds;
// Debounce
if (prevState.EventType != null && elapsed < _debounceSeconds)
{
_pointStates[point.TagName] = new PointAlarmState(currValue, now, prevState.EventType);
continue;
}
// Extract base tag (remove .inalm/.unackn suffix)
var baseTag = point.TagName!.EndsWith(".inalm")
? point.TagName[..^6]
: point.TagName!.EndsWith(".unackn")
? point.TagName[..^7]
: point.TagName;
// Determine event type based on inalm+unackn state machine
var isInalm = point.TagName!.EndsWith(".inalm");
var isUnackn = point.TagName!.EndsWith(".unackn");
var eventType = DeterminePointEventType(isInalm, isUnackn, prevState.Value, currValue);
if (eventType == null) continue; // no transition
// Get current PV value for curr_value
var pvTag = baseTag + ".pv";
var pvValue = _pvCache.GetValueOrDefault(pvTag) ?? "";
// Get area
var area = await db.GetAreaByTagNameAsync(baseTag);
var duration = (int)elapsed;
events.Add(new DigitalEventRecord
{
TagName = baseTag,
NodeId = point.NodeId,
PrevValue = prevState.Value,
CurrValue = pvValue,
EventType = eventType,
EventTime = now,
DurationSeconds = duration > 0 ? duration : null,
Area = area,
Section = ExtractSection(baseTag)
});
_pointStates[point.TagName] = new PointAlarmState(currValue, now, eventType);
}
}
if (events.Count > 0)
{
await db.BatchRecordDigitalEventsAsync(events);
_logger.LogInformation("[AlarmDetector] Point alarm 이벤트 기록: {Count}건", events.Count);
}
}
private string? DeterminePointEventType(bool isInalm, bool isUnackn, string? prevValue, string currValue)
{
bool prevBool = prevValue == "True" || prevValue == "1";
bool currBool = currValue == "True" || currValue == "1";
if (isInalm)
{
if (!prevBool && currBool) return "ALARM"; // 0→1: alarm 발생
if (prevBool && !currBool) return "NORMAL"; // 1→0: alarm 해제
}
else if (isUnackn)
{
if (prevBool && !currBool) return "CHANGE"; // 1→0: ack 처리됨 (metadata에 포함)
}
return null; // no event
}
private string? ExtractSection(string tagName)
{
var match = System.Text.RegularExpressions.Regex.Match(tagName, @"-(\d)(\d)\d{2}");
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
return null;
}
// <<< D3
// >>> D4: System Alarm 감지
private async Task LoadSystemAlarmStatesAsync(IExperionDbService db)
{
_systemStates.Clear();
var configs = await db.GetSystemAlarmConfigsAsync();
var groups = configs.GroupBy(c => c.InstanceName);
foreach (var g in groups)
{
_systemStates[g.Key] = new SystemAlarmState(
new Dictionary<string, string?>(), DateTime.UtcNow, null);
}
_logger.LogDebug("[AlarmDetector] System alarm 상태 로드: {Count}개 그룹", _systemStates.Count);
}
private async Task DetectSystemAlarmChangesAsync(IExperionDbService db, CancellationToken ct)
{
var configs = await db.GetSystemAlarmConfigsAsync();
if (!configs.Any()) return;
var currentPoints = await db.GetSystemAlarmPointsAsync();
if (ct.IsCancellationRequested) return;
// Build a lookup: nodeId → RealtimePoint
var pointLookup = currentPoints.ToDictionary(p => p.NodeId, p => p);
// Group configs by instance_name
var grouped = configs.GroupBy(c => c.InstanceName);
var events = new List<SystemAlarmEventRecord>();
foreach (var group in grouped)
{
var instName = group.Key;
var instType = group.First().InstanceType;
// Build current values snapshot for this instance
var currValues = new Dictionary<string, string?>();
foreach (var cfg in group)
{
if (pointLookup.TryGetValue(cfg.NodeId, out var pt))
currValues[cfg.AttrName] = pt.LiveValue;
}
// Get previous state
var prevState = _systemStates.GetValueOrDefault(instName);
if (prevState == null)
{
_systemStates[instName] = new SystemAlarmState(currValues, DateTime.UtcNow, null);
continue;
}
// Detect transitions
var now = DateTime.UtcNow;
var elapsed = (now - prevState.Timestamp).TotalSeconds;
var eventType = DetermineSystemEventType(prevState.Values, currValues);
if (eventType != null)
{
var duration = (int)elapsed;
// Serialize relevant fields as JSON snapshot
var snapshotFields = new[] { "pointinalarm", "status", "totalactivealarms", "unackalarmexists" };
var snapshot = new Dictionary<string, string?>();
foreach (var f in snapshotFields)
{
if (currValues.TryGetValue(f, out var v))
snapshot[f] = v;
}
events.Add(new SystemAlarmEventRecord
{
InstanceName = instName,
InstanceType = instType,
AttrName = "pointinalarm", // primary alarm indicator
PrevValue = prevState.Values.GetValueOrDefault("pointinalarm"),
CurrValue = JsonSerializer.Serialize(snapshot, _jsonOptions),
EventType = eventType,
EventTime = now,
DurationSeconds = duration > 0 ? duration : null
});
_systemStates[instName] = new SystemAlarmState(currValues, now, eventType);
}
}
if (events.Count > 0)
{
await db.BatchRecordSystemAlarmEventsAsync(events);
_logger.LogInformation("[AlarmDetector] System alarm 이벤트 기록: {Count}건", events.Count);
}
}
private string? DetermineSystemEventType(
Dictionary<string, string?> prevValues,
Dictionary<string, string?> currValues)
{
prevValues.TryGetValue("pointinalarm", out var prevStr);
currValues.TryGetValue("pointinalarm", out var currStr);
int.TryParse(prevStr, out var prevVal);
int.TryParse(currStr, out var currVal);
// pointinalarm: 0→>0 = ALARM
if (prevVal == 0 && currVal > 0) return "ALARM";
// pointinalarm: >0→0 = NORMAL
if (prevVal > 0 && currVal == 0) return "NORMAL";
// status change diagnostic (if same pointinalarm)
prevValues.TryGetValue("status", out var prevStatus);
currValues.TryGetValue("status", out var currStatus);
if (prevStatus != currStatus && currStatus != null)
{
if (currStatus == "0" || currStatus.Contains("BAD") || currStatus.Contains("FAIL"))
return "CHANGE"; // diagnostic
}
return null;
}
// <<< D4
}
Phase E — 컨트롤러 (src/Web/Controllers/ExperionControllers.cs)
// ===== System Alarm Endpoints =====
// >>> E1: POST /api/system-alarms/scan
[HttpPost("api/system-alarms/scan")]
public async Task<IActionResult> ScanSystemAlarms()
{
try
{
// 1. Discover system alarm instances from node_map_master
// signature: status:i=7594 + pointinalarm + totalactivealarms
var instancePrefixes = await _dbSvc.QueryMasterAsync(
minLevel: null, maxLevel: null, nodeClass: null,
names: new[] { "status" }, nodeId: null, dataType: "i=7594",
limit: 10000, offset: 0);
// Deduplicate by extracting base path
var baseNodes = new HashSet<string>();
var configs = new List<SystemAlarmConfig>();
var order = 0;
foreach (var item in instancePrefixes.Items)
{
var basePath = item.NodeId.Contains('.')
? item.NodeId[..item.NodeId.LastIndexOf('.')]
: item.NodeId;
if (!baseNodes.Add(basePath)) continue;
// Skip model types
if (basePath.Contains("$model") || basePath.Contains("$assetmodel")
|| basePath.Contains("$listmodel") || basePath.Contains("$canemodel")
|| basePath.Contains("$activitymodel") || basePath.Contains("$alarmgroupmodel")
|| basePath.Contains("$systemalarmgroupmodel") || basePath.Contains("$systemmodel"))
continue;
// Determine instance_type
string instType;
if (basePath.Contains("$srv") || basePath == "ns=1;s=$srvsinamserver")
instType = "server";
else if (basePath.Contains("$channel"))
instType = "channel";
else if (basePath.Contains("$controller") && !basePath.EndsWith("$controllers"))
instType = "controller";
else
instType = "group";
// Select attributes by type
var attrNames = instType switch
{
"server" => new[] { "status", "primarystatus", "backupstatus", "backupsyncstatus",
"link1status", "link2status", "datastatus", "notificationstatus",
"dsalink0state", "dsalink1state", "dsalink2state", "dsalink3state",
"pointinalarm", "unackalarmexists", "totalactivealarms" },
"channel" => new[] { "status", "pointinalarm", "unackalarmexists",
"totalactivealarms", "linkastatus", "linkbstatus", "connection",
"packetcount", "packetrate", "lnkapercenterrors", "lnkabarcount",
"utilization" },
"controller" => new[] { "status", "pointinalarm", "unackalarmexists",
"totalactivealarms", "linkastatus", "linkbstatus", "connection",
"packetcount", "packetrate", "lnkapercenterrors", "lnkabarcount",
"errorcode" },
_ => new[] { "status", "pointinalarm", "unackalarmexists", "totalactivealarms" }
};
// Extract instance name from basePath
var parts = basePath.Split(':');
var instName = parts.Length > 1 ? parts[^1] : basePath;
foreach (var attr in attrNames)
{
var nodeId = $"{basePath}.{attr}";
var tagName = instName.StartsWith("$")
? $"{instName}.{attr}"
: $"${instName}.{attr}";
configs.Add(new SystemAlarmConfig
{
InstanceName = instName,
InstanceType = instType,
AttrName = attr,
NodeId = nodeId,
TagName = tagName,
DisplayOrder = order++
});
}
}
// 2. Save configs
await _dbSvc.ClearSystemAlarmConfigsAsync();
if (configs.Count > 0)
await _dbSvc.SaveSystemAlarmConfigsAsync(configs);
// 3. Append to realtime_table
var nodeIds = configs.Select(c => c.NodeId).ToList();
if (nodeIds.Count > 0)
{
var added = await _dbSvc.AppendPointsAsync(nodeIds);
// 4. Hot-add to running subscription
var realtime = HttpContext.RequestServices.GetService<IExperionRealtimeService>();
if (realtime?.GetStatus().Running == true)
{
var hotAdded = 0;
foreach (var nid in nodeIds)
{
var (success, _) = await realtime.AddMonitoredItemAsync(nid);
if (success) hotAdded++;
}
}
}
return Ok(new { success = true, count = configs.Count,
instances = configs.Select(c => new { c.InstanceName, c.InstanceType, c.AttrName }).ToList() });
}
catch (Exception ex)
{
_logger.LogError(ex, "[SystemAlarm] Scan 실패");
return StatusCode(500, new { success = false, error = ex.Message });
}
}
// <<< E1
// >>> E2: POST /api/system-alarms/configure
[HttpPost("api/system-alarms/configure")]
public async Task<IActionResult> ConfigureSystemAlarms()
{
try
{
var configs = await _dbSvc.GetSystemAlarmConfigsAsync();
var nodeIds = configs.Select(c => c.NodeId).ToList();
var added = await _dbSvc.AppendPointsAsync(nodeIds);
var realtime = HttpContext.RequestServices.GetService<IExperionRealtimeService>();
if (realtime?.GetStatus().Running == true)
{
foreach (var nid in nodeIds)
await realtime.AddMonitoredItemAsync(nid);
}
return Ok(new { success = true, added, total = configs.Count });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, error = ex.Message });
}
}
// <<< E2
// >>> E3: DELETE /api/system-alarms/clear
[HttpDelete("api/system-alarms/clear")]
public async Task<IActionResult> ClearSystemAlarms()
{
try
{
var configs = await _dbSvc.GetSystemAlarmConfigsAsync();
var nodeIds = configs.Select(c => c.NodeId).ToHashSet();
// Delete from realtime_table
foreach (var nid in nodeIds)
{
var pt = (await _dbSvc.GetRealtimePointsAsync())
.FirstOrDefault(p => p.NodeId == nid);
if (pt != null)
await _dbSvc.DeleteRealtimePointAsync(pt.Id);
}
var cleared = await _dbSvc.ClearSystemAlarmConfigsAsync();
return Ok(new { success = true, removed = configs.Count });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, error = ex.Message });
}
}
// <<< E3
// >>> E4: GET /api/system-alarms/config
[HttpGet("api/system-alarms/config")]
public async Task<IActionResult> GetSystemAlarmConfig()
{
var configs = await _dbSvc.GetSystemAlarmConfigsAsync();
return Ok(new
{
success = true,
count = configs.Count,
configs = configs.Select(c => new
{
c.InstanceName,
c.InstanceType,
c.AttrName,
c.NodeId,
c.TagName
})
});
}
// <<< E4
// >>> E5: POST /api/system-alarms/append-point-alarms
[HttpPost("api/system-alarms/append-point-alarms")]
public async Task<IActionResult> AppendPointAlarms()
{
try
{
var added = await _dbSvc.AppendPointAlarmNodesAsync();
// Hot-add to subscription
if (added > 0)
{
var realtime = HttpContext.RequestServices.GetService<IExperionRealtimeService>();
if (realtime?.GetStatus().Running == true)
{
var newPoints = await _dbSvc.GetPointAlarmPointsAsync();
foreach (var p in newPoints)
await realtime.AddMonitoredItemAsync(p.NodeId);
}
}
return Ok(new { success = true, added });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, error = ex.Message });
}
}
// <<< E5
Phase F — Program.cs 등록 (src/Web/Program.cs)
기존 HostedService 등록 근처에 추가:
// >>> F1: AlarmDetectorService 등록
builder.Services.AddHostedService<AlarmDetectorService>();
// <<< F1