Files
ExperionCrawler/엑스페리온-전체알람-추가플랜.md
windpacer 302183c97e feat: P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링
- 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 업데이트
- 정리: 진단 체크리스트 문서 삭제
2026-05-21 23:36:57 +09:00

65 KiB
Raw Blame History

Experion 전체 알람 추가 플랜


⚠️ 재진단 (2026-05-20)

기준: diagnosis-checklist.md STEP 18 | 대상: 플랜 내 코드 템플릿 (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 18) | 교차검증: 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 && !currBoolCHANGE만 반환. 하지만 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)

문제: _pvCacheExecuteAsync 시작 시 한 번만 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.csSystemAlarmConfig 엔티티 추가
    • 필드: Id, InstanceName, InstanceType, AttrName, NodeId, TagName, DisplayOrder
    • Table: system_alarm_config
  • A2 src/Core/Domain/Entities/ExperionEntities.csSystemAlarmHistoryRecord 엔티티 추가
    • 필드: 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.csIExperionDbService에 메서드 추가:
    • 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.csDbSet<SystemAlarmConfig>, DbSet<SystemAlarmHistoryRecord> 추가
  • C2 src/Infrastructure/Database/ExperionDbContext.cs — DDL: system_alarm_config CREATE TABLE IF NOT EXISTS (기존 EnsureDatabaseAsync에 추가)
  • C3 src/Infrastructure/Database/ExperionDbContext.cs — DDL: system_alarm_history_table CREATE TABLE IF NOT EXISTS + indexes
  • C4 src/Infrastructure/Database/ExperionDbContext.csAppendPointAlarmNodesAsync() 구현
    • node_map_master에서 level=3 AND (name='inalm' OR name='unackn') 조회
    • 기존 realtime_table .pv base tag와 JOIN하여 대응되는 alarm 노드만 필터
    • 아직 realtime_table에 없는 node_id만 INSERT (기존 AppendPointsAsync 패턴)
    • 반환값: 추가된 개수
  • C5 src/Infrastructure/Database/ExperionDbContext.csGetSystemAlarmConfigsAsync() 구현
  • C6 src/Infrastructure/Database/ExperionDbContext.csSaveSystemAlarmConfigsAsync(List<SystemAlarmConfig>) 구현 (upsert 패턴)
  • C7 src/Infrastructure/Database/ExperionDbContext.csClearSystemAlarmConfigsAsync() 구현 (TRUNCATE or DELETE)
  • C8 src/Infrastructure/Database/ExperionDbContext.csBatchRecordSystemAlarmEventsAsync(IEnumerable<DigitalEventRecord>) 구현
  • C9 src/Infrastructure/Database/ExperionDbContext.csGetSystemAlarmPointsAsync() 구현
    • system_alarm_configrealtime_tablenode_id로 JOIN하여 현재값 조회
  • C10 src/Infrastructure/Database/ExperionDbContext.csGetPointAlarmPointsAsync() 구현
    • realtime_table에서 tagname LIKE '%.inalm' OR tagname 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.csPoint 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에 포함)
    • DigitalEventRecord 생성 → BatchRecordDigitalEventsAsync (기존 메서드) 호출
    • _debounceSeconds = 5 (기존과 동일)
  • D3 src/Infrastructure/OpcUa/AlarmDetectorService.csSystem Alarm 감지

    • LoadSystemAlarmStatesAsync() — 시작 시 GetSystemAlarmPointsAsync()로 현재값 로드
    • 상태 머신 (instance 단위로 그룹핑):
      • pointinalarm: 0→>0 = ALARM, >0→0 = NORMAL
      • status: 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.csPOST /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.csPOST /api/system-alarms/configure 엔드포인트

    • system_alarm_config 조회 → realtime_table에 없는 node_id만 append + hot-add
  • E3 src/Web/Controllers/ExperionControllers.csDELETE /api/system-alarms/clear 엔드포인트

    • system_alarm_config TRUNCATE + realtime_table 관련 항목 삭제
  • E4 src/Web/Controllers/ExperionControllers.csGET /api/system-alarms/config 엔드포인트 (조회용)

  • E5 src/Web/Controllers/ExperionControllers.csPOST /api/system-alarms/append-point-alarms 엔드포인트

    • AppendPointAlarmNodesAsync() 호출 + hot-add

Phase F — Registration

  • F1 src/Web/Program.csbuilder.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