# Experion 전체 알람 추가 플랜

---

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

> **기준**: diagnosis-checklist.md STEP 1~8 | **대상**: 플랜 내 코드 템플릿 (Phase A~E)
> **방법**: 실제 소스 코드(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
```csharp
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 노드만 필터:
```csharp
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
```csharp
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 사용:
```csharp
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
```csharp
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]의 수정안과 동일하게):
```csharp
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) | **교차검증**: Q1~Q4 모두 통과한 항목만 포함

---

### [1]. LoadSystemAlarmStatesAsync — 상태 사전 로드 누락 (HIGH)

**문제**: `LoadSystemAlarmStatesAsync()`에서 `system_alarm_config` 그룹만 순회하고 `realtime_table` 현재값을 조회하지 않아 `_systemStates`의 값 사전이 모두 빈 상태(`new Dictionary<string, string?>()`)로 초기화됨. 첫 감지 루프에서 `prevState.Values`가 모두 비어있어 `TryGetValue`가 항상 null을 반환 → 상태 전이 감지 불가.

**근거**: `엑스페리온-전체알람-추가플랜.md:892-903` (D2 코드 템플릿)
```csharp
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하여 현재값 채움:
```csharp
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 코드 템플릿)
```csharp
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 패턴 전달:
```csharp
// 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 코드 템플릿)
```csharp
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로 그룹핑하여 조합 판단:
```csharp
// 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 코드 템플릿)
```csharp
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 코드 템플릿)
```csharp
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` 메서드 추가:
```csharp
// 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)
```csharp
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` 값을 실시간으로 조회:
```csharp
// 루프 내부에 추가
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`

```sql
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`

```sql
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_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.cs` — `AppendPointAlarmNodesAsync()` 구현
  - 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.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'` 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.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에 포함)
  - `DigitalEventRecord` 생성 → `BatchRecordDigitalEventsAsync` (기존 메서드) 호출
  - `_debounceSeconds = 5` (기존과 동일)

- [ ] **D3** `src/Infrastructure/OpcUa/AlarmDetectorService.cs` — **System 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.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개 클래스를 추가:

```csharp
// >>> 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` 메서드 **바로 다음**에 추가:

```csharp
    // ── 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` 클래스 **다음**에 추가:

```csharp
// >>> 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에 추가

```csharp
// >>> 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 다음)

```csharp
// >>> 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` 근처에 추가)

```csharp
// >>> 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

```csharp
// >>> 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

```csharp
// >>> 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

```csharp
// >>> 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

```csharp
// >>> 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`)

신규 파일, 전체 내용:

```csharp
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`)

```csharp
// ===== 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 등록 근처에 추가:

```csharp
// >>> F1: AlarmDetectorService 등록
builder.Services.AddHostedService<AlarmDetectorService>();
// <<< F1
```
