Files
ExperionCrawler/plans/이벤트-히스토리-테이블-플랜.md

21 KiB

이벤트 기반 디지털 포인트 히스토리 테이블 플랜

1. 개요

목적

  • 기존 periodic snapshot (60초마다)에서 event-driven 방식으로 변경
  • 디지털 포인트의 상태 변경 時만 기록하여 스토리지 절약

현재 상태

  • ExperionHistoryService가 60초마다 realtime_table 전체를 history_table에 스냅샷
  • 디지털/아날로그 구분 없이 모든 포인트가 Periodic으로 기록

2. Phase 1: 기존 히스토리 루틴에서 디지털 포인트 제외

구현 위치

src/Infrastructure/Database/ExperionDbContext.cs:730 - SnapshotToHistoryAsync()

변경 내용

  1. 디지털 태그 식별 기준 정의
  2. 기존 SnapshotToHistoryAsync 메서드에 Where 조건 추가

예상 코드 변경

// 메서드 시그니처에 디지털 필터 옵션 추가
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
    var now = DateTime.UtcNow;
    var query = _ctx.RealtimePoints.AsQueryable();

    // 디지털 제외 (기본값)
    if (!includeDigital)
    {
        var digitalTagNames = await GetDigitalTagNamesAsync();
        query = query.Where(p => !digitalTagNames.Contains(p.TagName));
    }

    var points = await query.ToListAsync();
    if (points.Count == 0) return 0;

    var rows = points.Select(p => new HistoryRecord
    {
        TagName    = p.TagName,
        NodeId     = p.NodeId,
        Value      = p.LiveValue,
        RecordedAt = now
    }).ToList();

    await _ctx.HistoryRecords.AddRangeAsync(rows);
    var saved = await _ctx.SaveChangesAsync();
    _logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
    return saved;
}

// 디지털 태그 목록 조회 (캐싱 고려)
private async Task<HashSet<string>> GetDigitalTagNamesAsync()
{
    // node_map_master에서 data_type = i=7594 인 태그 조회
    // 또는 tag_metadata에서 value 패턴이 {X | STATE | } 형태인 태그
}

3. Phase 2: 이벤트 히스토리 테이블 설계

테이블: event_history_table

-- 이벤트 히스토리 테이블 생성
CREATE TABLE event_history_table (
    id BIGSERIAL PRIMARY KEY,
    tagname TEXT NOT NULL,
    node_id TEXT NOT NULL,
    prev_value TEXT,           -- 이전 상태 (예: "{0 | L-STOP | }")
    curr_value TEXT,          -- 현재 상태 (예: "{0 | RUN | }")
    event_type TEXT NOT NULL,  -- CHANGE, TRIP, RUN, ALARM, NORMAL, INTERLOCK
    event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    area TEXT,                 -- P1, P6, P8 등 (tag_metadata에서 조회)
    section TEXT,             -- 6-1차, 6-2차 (태그 번호 기반: 61xx=6-1차, 62xx=6-2차)
    duration_seconds INT,     -- 이전 상태 지속 시간 (초)
    metadata JSONB,            -- 추가 정보 (선택): {"alarm_priority": 3, "interlock_tag": "lica-6113-trip"}
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 인덱스 (쿼리 성능 최적화)
CREATE INDEX idx_event_history_tagname_time ON event_history_table(tagname, event_time DESC);
CREATE INDEX idx_event_history_area_time ON event_history_table(area, event_time DESC);
CREATE INDEX idx_event_history_section_time ON event_history_table(section, event_time DESC);
CREATE INDEX idx_event_history_event_type ON event_history_table(event_type, event_time DESC);
CREATE INDEX idx_event_history_tagname_event_type ON event_history_table(tagname, event_type, event_time DESC);

-- 테이블 설명 주석
COMMENT ON TABLE event_history_table IS '디지털 포인트 상태 변경 이벤트 히스토리';
COMMENT ON COLUMN event_history_table.event_type IS 'CHANGE: 일반 변경, TRIP: 정지, RUN: 가동, ALARM: 알람, NORMAL: 정상복귀, INTERLOCK: 인터록';

event_type 정의

event_type 설명 트리거 조건
CHANGE 일반 상태 변경 prev_value != curr_value
TRIP 장치 정지 curr_value에 "L-STOP", "STOP", "TRIP" 포함
RUN 장치 가동 curr_value에 "RUN", "START" 포함
ALARM 알람 발생 alarm 상태 감지
NORMAL 정상 복귀 alarm clear
INTERLOCK 인터록 발생 인터록 관련 태그 (-il-rst, -trip)
SHUTDOWN 계획정지 명시적 shutdown 신호
STARTUP 계획가동 명시적 startup 신호

4. Phase 3: 디지털 포인트 상태변화 감지 및 기록

4.1 새 서비스: DigitalEventDetectorService

파일: src/Infrastructure/OpcUa/DigitalEventDetectorService.cs

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;

namespace ExperionCrawler.Infrastructure.OpcUa;

/// <summary>
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
/// </summary>
public class DigitalEventDetectorService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<DigitalEventDetectorService> _logger;
    private readonly Dictionary<string, DigitalPointState> _previousStates = new();
    private readonly HashSet<string> _digitalTagNames = new();
    private readonly ConcurrentDictionary<string, string> _areaCache = new();
    private readonly int _checkIntervalMs = 1000;
    private readonly int _debounceSeconds = 5; // 동일 상태 반복 방지

    private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);

    public DigitalEventDetectorService(
        IServiceScopeFactory scopeFactory,
        ILogger<DigitalEventDetectorService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);

        // 초기 디지털 태그 목록 로드
        await LoadDigitalTagNamesAsync();
        // 현재 상태 로드 (서비스 재시작 시 상태 손실 방지)
        await LoadCurrentStatesAsync();

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_checkIntervalMs, stoppingToken);
                await DetectAndRecordChangesAsync();
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                _logger.LogError(ex, "[DigitalEventDetector] 감지 오류");
            }
        }

        _logger.LogInformation("[DigitalEventDetector] 종료");
    }

    private async Task LoadDigitalTagNamesAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        var digitalTags = await db.GetDigitalTagNamesAsync();
        _digitalTagNames.UnionWith(digitalTags);
        _logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", _digitalTagNames.Count);
    }

    private async Task LoadCurrentStatesAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        var points = await db.GetDigitalPointsAsync();
        foreach (var p in points)
            _previousStates[p.TagName] = new DigitalPointState(p.LiveValue, DateTime.UtcNow, null);
        _logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
    }

    private async Task DetectAndRecordChangesAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        // 디지털 포인트만 조회
        var currentPoints = await db.GetDigitalPointsAsync();

        foreach (var point in currentPoints)
        {
            var tagName = point.TagName;
            var currValue = point.LiveValue;

            // 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함)
            if (!_previousStates.TryGetValue(tagName, out var prevState))
            {
                _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
                continue;
            }

            // 값 변경 감지
            if (prevState.Value != currValue)
            {
                var eventType = DetermineEventType(prevState.Value, currValue);

                // Debounce: 동일 상태로의 반복만 방지, 상태 전환(TRIP→RUN 등)은 항상 기록
                if (prevState.EventType == eventType && prevState.Value == currValue &&
                    (DateTime.UtcNow - prevState.Timestamp).TotalSeconds < _debounceSeconds)
                {
                    _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
                    continue;
                }

                // 이벤트 기록
                var duration = (int)(DateTime.UtcNow - prevState.Timestamp).TotalSeconds;
                await db.RecordDigitalEventAsync(new DigitalEventRecord
                {
                    TagName = tagName,
                    NodeId = point.NodeId,
                    PrevValue = prevState.Value,
                    CurrValue = currValue,
                    EventType = eventType,
                    EventTime = DateTime.UtcNow,
                    DurationSeconds = duration,
                    Area = ExtractArea(tagName),
                    Section = ExtractSection(tagName),
                    Metadata = BuildMetadata(tagName, eventType, currValue)
                });

                _logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr})",
                    tagName, eventType, prevState.Value, currValue);

                _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
            }
        }
    }

    private string DetermineEventType(string? prevValue, string currValue)
    {
        if (currValue.Contains("L-STOP") || currValue.Contains("STOP") || currValue.Contains("TRIP"))
            return "TRIP";
        if (currValue.Contains("RUN") || currValue.Contains("START"))
            return "RUN";
        if (currValue.Contains("ALARM"))
            return "ALARM";
        if (prevValue?.Contains("ALARM") == true && !currValue.Contains("ALARM"))
            return "NORMAL";
        return "CHANGE";
    }

    private string? ExtractArea(string tagName)
    {
        if (_areaCache.TryGetValue(tagName, out var area)) return area;

        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        area = db.GetAreaByTagNameAsync(tagName).GetAwaiter().GetResult();

        if (area != null)
            _areaCache[tagName] = area;
        return area;
    }

    private string? ExtractSection(string tagName)
    {
        // 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
        // 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
        var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
        if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
        return "기타";
    }

    private string? BuildMetadata(string tagName, string eventType, string currValue)
    {
        // 인터록 태그인 경우 메타데이터 추가
        if (tagName.Contains("-il-") || tagName.Contains("-trip"))
        {
            return System.Text.Json.JsonSerializer.Serialize(new
            {
                interlock_tag = tagName,
                event_type = eventType,
                raw_value = currValue
            });
        }
        return null;
    }
}

4.2 IExperionServices 인터페이스 확장

참고: DigitalEventRecord는 서비스 계층 DTO, EventHistoryRecord는 EF Core Entity 클래스로 별도 정의 필요

// src/Core/Application/Interfaces/IExperionServices.cs에 추가
public interface IExperionDbService
{
    // ... 기존 메서드 ...

    /// <summary>디지털 태그 이름 목록 조회</summary>
    Task<IEnumerable<string>> GetDigitalTagNamesAsync();

    /// <summary>디지털 포인트 현재 값 조회</summary>
    Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();

    /// <summary>디지털 이벤트 기록</summary>
    Task<int> RecordDigitalEventAsync(DigitalEventRecord record);

    /// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
    Task<string?> GetAreaByTagNameAsync(string tagName);
}

// 새로운 모델 클래스 (서비스 계층 DTO)
public class DigitalEventRecord
{
    public string TagName { get; set; } = "";
    public string NodeId { get; set; } = "";
    public string? PrevValue { get; set; }
    public string CurrValue { get; set; } = "";
    public string EventType { get; set; } = "";
    public DateTime EventTime { get; set; }
    public int? DurationSeconds { get; set; }
    public string? Area { get; set; }
    public string? Section { get; set; }
    public string? Metadata { get; set; }
}

4.2.1 EventHistoryRecord Entity 클래스 정의

// src/Core/Domain/Entities/ExperionEntities.cs에 추가
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
[Table("event_history_table")]
public class EventHistoryRecord
{
    [Column("id")]             public int     Id              { get; set; }
    [Column("tagname")]        public string  TagName         { get; set; } = string.Empty;
    [Column("node_id")]        public string  NodeId          { 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;
    [Column("event_time")]     public DateTime EventTime      { get; set; } = DateTime.UtcNow;
    [Column("area")]           public string? Area            { get; set; }
    [Column("section")]        public string? Section         { get; set; }
    [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;
}

4.3 ExperionDbContext 구현 추가

// src/Infrastructure/Database/ExperionDbContext.cs에 추가

public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
{
    // node_map_master에서 data_type = 'i=7594' 인 태그 조회
    // 또는 정규식으로 value 패턴이 {X | STATE | } 형태인 태그
    return await _ctx.RealtimePoints
        .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
        .Select(p => p.TagName)
        .ToListAsync();
}

public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
    return await _ctx.RealtimePoints
        .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
        .ToListAsync();
}

public async Task<string?> GetAreaByTagNameAsync(string tagName)
{
    // tag_metadata에서 base_tag=tagName, attribute='area' 조회
    // value = "{12 | P6 | }" → "P6" 파싱
    var meta = await _ctx.TagMetadata
        .Where(m => m.BaseTag == tagName && m.Attribute == "area")
        .Select(m => m.Value)
        .FirstOrDefaultAsync();

    if (string.IsNullOrEmpty(meta)) return null;

    // "{12 | P6 | }" 패턴에서 area 코드 추출
    var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
    return match.Success ? match.Groups[1].Value : null;
}

public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
{
    var row = new EventHistoryRecord
    {
        TagName    = record.TagName,
        NodeId     = record.NodeId,
        PrevValue  = record.PrevValue,
        CurrValue  = record.CurrValue,
        EventType  = record.EventType,
        EventTime  = record.EventTime,
        DurationSeconds = record.DurationSeconds,
        Area       = record.Area,
        Section    = record.Section,
        Metadata   = record.Metadata
    };

    await _ctx.EventHistoryRecords.AddAsync(row);
    return await _ctx.SaveChangesAsync();
}

// DbSet 추가
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();

4.4 Program.cs 등록

// src/Web/Program.cs에 추가
builder.Services.AddHostedService<DigitalEventDetectorService>();

5. Phase 4: 보고서 기능 설계

5.1 요구사항 분석

입력 예시: "지난밤 6차 플랜트의 현황 보고"

출력 형식:

[제목] 2026-05-10 6차 플랜트 현황 보고
[시간] 2026-05-09 00:00:00 ~ 2026-05-10 00:00:00

[6-1차 (태그명: %-61%)]
- 00:00:00 p-6102 Trip 정지 → 00:05:30 가동 (정지 시간: 5분 30초)
- 알람 발생: 3회
- ficq-6101.qv.value (투입량): 12,500 kg
- ficq-6118.qv.value (생산량): 11,200 kg

[6-2차 (태그명: %-62%)]
- 02:30:00 p-6201 Trip 정지 → 02:45:00 가동 (정지 시간: 15분)
- 알람 발생: 1회

5.2 필요한 데이터

데이터 출처 설명
구간별 이벤트 event_history_table 6-1차 (61xx), 6-2차 (62xx)
Trip/Run 쌍 event_history_table 정지~가동 시간 계산
알람 횟수 event_history_table event_type = 'ALARM' count
투입량 history_table 또는 별도 테이블 ficq-6101, ficq-6201
생산량 history_table 또는 별도 테이블 ficq-6118, ficq-6218

5.3 구간(세션) 구분 로직

참고: Phase 3의 ExtractSection과 동일한 로직으로 통일 (진단 HIGH #1 반영)

private string? ExtractSection(string tagName)
{
    // 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
    // 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
    var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
    if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
    return "기타";
}

5.4 적산값 처리

문제: history_table의 periodic snapshot에서 차이를 계산하면 노이즈가 많음

해결방안: event_history_table에 적산 정보 기록

// DigitalEventDetectorService에 추가
// TRIP 발생 시 현재 적산값 저장
// RUN 발생 시 (TRIP 시점 적산값 - 현재 적산값) = 정지 시간 동안의 미투입량

// 또는 별도 테이블
CREATE TABLE accumulated_events (
    id BIGSERIAL PRIMARY KEY,
    tagname TEXT NOT NULL,
    event_type TEXT NOT NULL,  -- TRIP_START, RUN_START
    value_at_event DOUBLE,
    event_time TIMESTAMPTZ NOT NULL
);

5.5 보고서 쿼리 예시

-- 6차 플랜트 6-1차 구간 이벤트 요약
SELECT
    section,
    event_type,
    COUNT(*) as count,
    MIN(event_time) as first_occurrence,
    MAX(event_time) as last_occurrence
FROM event_history_table
WHERE area = 'P6'
  AND section = '6-1차'
  AND event_time BETWEEN '2026-05-09 00:00:00' AND '2026-05-10 00:00:00'
GROUP BY section, event_type
ORDER BY event_type;

-- Trip별 정지 시간 계산 (TRIP → RUN 쌍 찾기)
WITH trip_events AS (
    SELECT
        tagname,
        event_time as trip_time,
        LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time) as run_time,
        LEAD(event_type) OVER (PARTITION BY tagname ORDER BY event_time) as next_event_type
    FROM event_history_table
    WHERE event_type = 'TRIP' AND area = 'P6'
)
SELECT
    tagname,
    trip_time,
    run_time,
    EXTRACT(EPOCH FROM (run_time - trip_time)) / 60 as duration_minutes
FROM trip_events
WHERE next_event_type = 'RUN';

6. 구현 로드맵

Phase 작업 우선순위 예상 시간
1 디지털 태그 식별 로직 추가 높음 1일
1 SnapshotToHistoryAsync 수정 높음 0.5일
2 event_history_table 생성 높음 0.5일
3 DigitalEventDetectorService 구현 높음 2일
3 IExperionServices 확장 높음 1일
4 보고서 쿼리 작성 중간 2일
4 API 엔드포인트 추가 중간 1일

7. 고려사항

7.1 중복 이벤트 방지

  • Debounce 로직 적용 (기본 5초)
  • 동일 상태로의 반복 발생 시 무시

7.2 마이그레이션

  • 기존 history_table의 디지털 데이터 보존 또는 별도 아카이브
  • 새로운 event_history_table로의 데이터 이전 (선택)

7.3 성능

  • 디지털 태그 수: ~500개
  • 감지 주기: 1초
  • 예상 INSERT: 초당 수십 건 (변경 시)

7.4 확장성

  • 알람 우선순위 정보 메타데이터에 추가
  • 인터록 체인 추적 (상위/interlock 원인 태그 기록)

8. 부록: 테스트 계획

8.1 단위 테스트

  • DetermineEventType 메서드 테스트
  • ExtractSection 메서드 테스트

8.2 통합 테스트

  • 실제 OPC UA 연결 없이 모의 데이터로 동작 확인
  • event_history_table에 올바른 기록 확인

8.3 성능 테스트

  • 500개 디지털 태그 처리 시 CPU/메모리 사용량
  • DB INSERT 성능

9. 참고 자료

  • OPC UA LocalizedText (i=7594): 로컬라이즈된 텍스트 타입, {locale | text | } 형태
  • Experion 태그 명명 규칙: {type}-{unit}{number}.{attribute}
  • P6 플랜트 구조: 61xx = 6-1차, 62xx = 6-2차