opencode 로 바꾸고 작업전 커밋

This commit is contained in:
windpacer
2026-05-08 17:22:10 +09:00
parent 15c17522c8
commit e923aab43b
202 changed files with 1336027 additions and 115 deletions

View File

@@ -0,0 +1,804 @@
# 디지털 태그 추가 작업 계획 (Phase 1)
## 개요
`realtime-tag-expansion-design.md` 설계안을 기반으로 Phase 1 기본 확장을 구현합니다.
기존 `realtime_table` 구조를 유지하면서 디지털 장비(Pump, XV) 태그와 메타데이터 지원을 추가합니다.
---
## Todo List (작업 진행 상태 추적)
> 각 단계는 독립적으로 실행 가능하며, 완료 상태를 표시하여 다음 작업에서 바로 읽을 수 있습니다.
- [x] **Step 1**: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
- [x] **Step 2**: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
- [x] **Step 3**: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
- [x] **Step 4**: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
- [x] **Step 5**: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
- [x] **Step 6**: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가 + `Program.cs` SqlValidatorOptions `AllowedTables` 업데이트
- [ ] **Step 7**: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인 (수동 테스트 필요)
---
## Step 1: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
### 상태: [x] 완료
### 변경 파일
- `src/Infrastructure/Database/ExperionDbContext.cs`
### 변경 내용
[`ExperionDbService.InitializeAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:176) 메서드 내에 다음 DDL을 추가합니다.
기존 DDL(`realtime_table` 생성 이후, `EnsureCreatedAsync` 설명 주석 이전)에 추가합니다.
**추가 위치:** 275번 라인 (`// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음`) 직후
```sql
// tag_metadata ( - )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
""");
// v_tag_summary ( + )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
### 검증 방법
1. 앱을 실행하여 DB 초기화가 자동으로 수행되는지 확인
2. psql로 접속하여 `SELECT * FROM tag_metadata LIMIT 1;` 실행
3. `SELECT * FROM v_tag_summary LIMIT 5;` 실행하여 뷰가 정상 동작하는지 확인
---
## Step 2: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
### 상태: [x] 완료
### 변경 파일
- `src/Core/Domain/Entities/ExperionEntities.cs` (신규 Entity 추가)
- `src/Infrastructure/Database/ExperionDbContext.cs` (DbSet + ModelBuilder 등록)
### 2.1 신규 Entity 클래스 추가
**파일:** [`src/Core/Domain/Entities/ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:138)
[`FastRecord`](src/Core/Domain/Entities/ExperionEntities.cs:129) 클래스 직후에 다음 Entity를 추가합니다.
```csharp
/// <summary>tag_metadata — 태그 메타데이터 (변경 드묾)</summary>
[Table("tag_metadata")]
public class TagMetadata
{
[Column("id")] public int Id { get; set; }
[Column("base_tag")] public string BaseTag { get; set; } = string.Empty;
[Column("attribute")] public string Attribute { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("node_id")] public string? NodeId { get; set; }
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
}
```
### 2.2 DbContext 에 DbSet 등록
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:20)
기존 DbSet 목록 ([`FastRecords`](src/Infrastructure/Database/ExperionDbContext.cs:23) 이후, P&ID DbSet 이전) 에 추가:
```csharp
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
```
### 2.3 ModelBuilder 설정 추가
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:30)
[`OnModelCreating()`](src/Infrastructure/Database/ExperionDbContext.cs:30) 메서드 내, [`FastRecord`](src/Infrastructure/Database/ExperionDbContext.cs:75) 설정 이후, P&ID 엔티티 설정 이전에 추가:
```csharp
// DDL에서 UNIQUE(base_tag, attribute)와 idx_tag_metadata_base 인덱스를 이미 생성하므로
// ModelBuilder 설정은 EF Core Migration 호환성을 위해 유지하되 실제 인덱스 생성은 DDL이 담당
modelBuilder.Entity<TagMetadata>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique(); // DDL UNIQUE 제약과 대응
e.HasIndex(x => x.BaseTag); // DDL idx_tag_metadata_base와 대응
});
```
### 2.4 변경 후 파일 구조 확인
[`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs) 최종 구조:
```
ExperionTag (line 7)
ExperionRecord (line 21)
ExperionServerConfig (line 32)
RawNodeMap (line 47)
NodeMapMaster (line 59)
RealtimePoint (line 71)
HistoryRecord (line 82)
ExperionStatusCodeInfo (line 92)
PidGraphStatus (line 102)
FastSession (line 113)
FastRecord (line 130)
TagMetadata (신규 추가) ← 여기
```
[`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs) DbSet 최종 구조:
```csharp
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>(); // ← 신규
// P&ID DbSet ...
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Entity 클래스가 `[Table("tag_metadata")]` 속성으로 DB 테이블과 매핑되는지 확인
3. DbContext에서 `TagMetadata` DbSet를 통해 CRUD가 가능한지 확인
---
## Step 3: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
### 상태: [x] 완료
### 신규 파일
- `src/Infrastructure/OpcUa/MetadataLoaderService.cs`
### 변경 파일
- `src/Core/Application/Interfaces/IExperionServices.cs` (인터페이스 추가)
- `src/Web/Program.cs` (DI 등록)
### 3.1 인터페이스 정의
**파일:** [`src/Core/Application/Interfaces/IExperionServices.cs`](src/Core/Application/Interfaces/IExperionServices.cs)
파일 끝에 다음 인터페이스 추가:
```csharp
// ── Metadata Loader ──────────────────────────────────────────────────────────
public interface IMetadataLoaderService
{
/// <summary>
/// 태그 등록 시 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 한 번 읽어서 tag_metadata 저장
/// </summary>
Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags);
/// <summary>
/// UI 재로드 버튼 클릭 시 메타데이터 재조회 + tag_metadata UPDATE
/// 참고: node_map_master UPSERT는 Phase 2 자동 속성 제안 기능에서 처리 예정
/// </summary>
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
```
### 3.2 서비스 구현
**파일:** `src/Infrastructure/OpcUa/MetadataLoaderService.cs` (신규)
> **수정 이력 (진단):**
> - `using Npgsql;` 추가 (누락 시 빌드 오류)
> - `NpgsqlTypes.NpgsqlParameter` → `Npgsql.NpgsqlParameter` (잘못된 네임스페이스)
> - CTE 컬럼 목록에 `loaded_at` 추가 (4열 선언 vs 5값 불일치 → SQL 런타임 오류)
> - 사용되지 않는 `updateSql` 변수 제거 (데드코드)
> - `baseTags`를 메서드 진입 시 `ToList()`로 구체화 (이중 열거 방지)
```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var baseTagList = baseTags.ToList(); // 이중 열거 방지
// ── Step 1: 모든 노드 ID 수집 ──────────────────────────────────────
var nodeMap = new Dictionary<string, (string baseTag, string attr)>();
foreach (var baseTag in baseTagList)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
nodeMap[fullNodeId] = (baseTag.ToLowerInvariant(), attr);
}
}
// ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ────────────────────────
var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys);
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var result in results)
{
if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta))
{
entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId));
}
}
// ── Step 3: 단일 배치 UPSERT ──────────────────────────────────────
if (entries.Count > 0)
{
// VALUES 절을 동적으로 생성하여 한 번에 INSERT
// CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at
var valuesSql = string.Join(", ", entries.Select((e, i) =>
$"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())"));
await _ctx.Database.ExecuteSqlRawAsync(@"
WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS (
VALUES " + valuesSql + @"
)
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()",
entries.SelectMany((e, i) => new object[] {
new NpgsqlParameter($"@bt{i}", e.baseTag),
new NpgsqlParameter($"@attr{i}", e.attr),
new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value),
new NpgsqlParameter($"@nid{i}", e.nodeId)
}).ToArray());
}
// v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN)
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagList.Count);
return entries.Count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags?.ToList() ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}
```
### 3.3 DI 등록
**파일:** `src/Web/Program.cs`
기존 서비스 등록 부분 근처에 추가:
```csharp
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. 테스트용 코드에서 `LoadMetadataAsync` 호출 시 OPC UA에서 desc/area 값이 정상 읽히는지 로그 확인
3. DB에서 `SELECT * FROM tag_metadata;` 실행하여 데이터 저장 확인
---
## Step 4: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
### 상태: [x] 완료
### 변경 파일
- `src/Web/Controllers/ExperionControllers.cs` (신규 컨트롤러 추가)
### 4.1 컨트롤러 추가 (최종 형태)
**파일:** [`src/Web/Controllers/ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs)
[`ExperionPointBuilderController`](src/Web/Controllers/ExperionControllers.cs:272) 직후, [`ExperionRealtimeController`](src/Web/Controllers/ExperionControllers.cs:348) 이전에 추가:
```csharp
// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly ExperionDbContext _ctx;
private readonly IConfiguration _config;
public TagMetadataController(
IMetadataLoaderService metaSvc,
ExperionDbContext ctx,
IConfiguration config)
{
_metaSvc = metaSvc;
_ctx = ctx;
_config = config;
}
/// <summary>
/// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
/// 설정은 appsettings.json의 "Experion:Default" 섹션에서 읽음
/// </summary>
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
// appsettings.json에서 기본 설정 읽기 (비밀번호 하드코딩 금지)
var section = _config.GetSection("Experion:Default");
var cfg = new ExperionServerConfig
{
ServerHostName = req?.ServerHostName ?? section["ServerHostName"] ?? throw new InvalidOperationException("ServerHostName이 설정되지 않았습니다."),
Port = req?.Port ?? section.GetValue<int?>("Port") ?? 4840,
ClientHostName = req?.ClientHostName ?? section["ClientHostName"] ?? "dbsvr",
UserName = req?.UserName ?? section["UserName"] ?? "",
Password = req?.Password ?? section["Password"] ?? ""
};
try
{
var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>
/// tag_metadata 전체 조회
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
var query = _ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag, t.Attribute, t.Value, t.NodeId, t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
public record MetadataReloadRequest(
string? ServerHostName,
int? Port,
string? ClientHostName,
string? UserName,
string? Password,
IEnumerable<string>? BaseTags);
```
> **주의**: `appsettings.json`에 다음 섹션이 있어야 합니다:
> ```json
> {
> "Experion": {
> "Default": {
> "ServerHostName": "192.168.0.20",
> "Port": 4840,
> "ClientHostName": "dbsvr",
> "UserName": "mngr",
> "Password": "mngr"
> }
> }
> }
> ```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Postman/curl로 `POST /api/tags/metadata/reload` 호출하여 메타데이터 갱신 확인
3. `GET /api/tags/metadata?baseTag=xv-6124` 호출하여 조회 확인
---
## Step 5: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
### 상태: [x] 완료
### 변경 파일
- `src/Web/wwwroot/index.html` (포인트빌더 탭 HTML 변경)
- `src/Web/wwwroot/js/app.js` (JS 로직 변경)
### 5.1 HTML 변경 — 포인트빌더 최대 10개로 확장
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:422)
현재 포인트빌더 탭(`pane-pb`) 내 name 선택 드롭다운이 8개(`pb-n1`~`pb-n8`)입니다. 10개로 확장:
**변경 위치:** `pb-name-grid` 내부 select 요소 2개 추가
```html
<!-- 신규 추가 -->
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
```
**레이블 텍스트 변경:**
```html
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
```
### 5.2 HTML 변경 — 메타데이터 재로드 버튼 추가
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:482)
`pb-rt-status` 로그 박스 직후, 포인트 목록 카드 이전에 추가:
```html
<div class="card" style="margin-top:18px">
<div class="card-cap">메타데이터 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
</p>
<div class="btn-row">
<button class="btn-a" onclick="metaReload()">🔄 메타데이터 재로드</button>
<button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
</div>
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
</div>
```
### 5.3 JS 변경 — PB_NAME_IDS 배열 확장
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:575)
```javascript
// 변경 전:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
// 변경 후:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
```
### 5.4 JS 변경 — 메타데이터 재로드/조회 함수 추가
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:723)
`rtStatus()` 함수 직후에 추가:
```javascript
/* ── 메타데이터 관리 ─────────────────────────────────────────── */
async function metaReload() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
const logEl = document.getElementById('meta-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
```
### 검증 방법
1. 브라우저에서 포인트빌더 탭 확인 — 드롭다운이 10개인지 확인
2. "메타데이터 재로드" 버튼 클릭 시 API 호출되고 결과 로그 표시 확인
3. "메타데이터 조회" 버튼 클릭 시 테이블 형태로 메타데이터 표시 확인
---
## Step 6: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
### 상태: [x] 완료
### 변경 파일
- `mcp-server/worker/nl2sql_worker.py` (DB_SCHEMA 변수 업데이트)
- `mcp-server/server.py` (_DB_SCHEMA 변수 업데이트)
### 6.1 nl2sql_worker.py 변경
**파일:** [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:61)
기존 `DB_SCHEMA` 변수에 `tag_metadata` 테이블과 `v_tag_summary` 뷰 정의를 추가:
```python
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv')
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지
"""
```
### 6.2 server.py 변경
**파일:** [`mcp-server/server.py`](mcp-server/server.py:430)
`_DB_SCHEMA` 변수도 동일하게 업데이트 (nl2sql_worker.py와 동일한 내용).
### 6.3 Program.cs SqlValidatorOptions 업데이트
> **진단에서 발견:** `Program.cs`의 C# `SqlValidator`가 `AllowedTables`를 화이트리스트로 관리함.
> `tag_metadata`, `v_tag_summary`, `realtime_table`이 없으면 C# NL2SQL 경로(`ITextToSqlService`)에서 이 테이블을 조회하는 SQL이 거부됨.
> MCP Python 경로는 자체 검증 로직을 사용하므로 별도 영향 없음.
**파일:** `src/Web/Program.cs` (68~73번 라인)
```csharp
// 변경 전:
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master"],
MaxSubqueryDepth = 4
});
// 변경 후:
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master", "realtime_table", "tag_metadata", "v_tag_summary"],
MaxSubqueryDepth = 4
});
```
### 6.4 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
### 검증 방법
1. MCP 서버 재시작 후 NL2SQL 도구 호출 시 새로운 스키마가 반영되는지 확인
2. "xv-6124 상태 알려줘" 같은 질문으로 v_tag_summary 조회 테스트
3. tag_metadata 테이블 조회가 정상적으로 되는지 확인
4. `Program.cs` SqlValidatorOptions 업데이트 후 C# NL2SQL 경로에서도 `tag_metadata`, `v_tag_summary` 쿼리 허용 확인
---
## Step 7: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
### 상태: [ ] 수동 테스트 필요
### 테스트 시나리오
#### 7.1 디지털 태그 등록 테스트
**목표:** xv-6124의 pv, op, instate0~2를 realtime_table에 등록하고 OPC UA 구독 확인
**단계:**
1. 포인트빌더 탭에서 이름에 `xv-6124` 선택
2. 데이터 타입에 `Int32`, `Boolean` 입력
3. "🔨 테이블 작성하기" 클릭
4. 포인트 목록에 xv-6124.pv, xv-6124.op, xv-6124.instate0~2가 생성되는지 확인
**기대 결과:**
- `realtime_table`에 xv-6124 관련 행이 INSERT됨
- 포인트 목록 UI에 표시됨
#### 7.2 OPC UA 구독 테스트
**목표:** 실시간 값 업데이트 확인
**단계:**
1. "▶ 구독 시작" 클릭
2. 구독 상태 로그에 xv-6124 관련 노드가 포함된지 확인
3. 몇 분 후 포인트 목록 새로고침
4. `livevalue` 컬럼에 값이 업데이트되는지 확인
**기대 결과:**
- xv-6124.pv → Int32 BCD 값 (예: 0, 1, 3, 5 등)
- xv-6124.instate0 → Boolean (true/false)
- 구독 카운터에 xv-6124 태그가 포함됨
#### 7.3 메타데이터 로드 테스트
**목표:** desc, area, state descriptor 값이 tag_metadata에 저장되는지 확인
**단계:**
1. "🔄 메타데이터 재로드" 버튼 클릭
2. 로그에 갱신된 속성 수 확인
3. "📋 메타데이터 조회" 버튼 클릭
4. xv-6124 관련 메타데이터 행 확인
**기대 결과:**
- `tag_metadata`에 xv-6124 관련 행 INSERT됨
- desc: 장비 설명 문자열
- area: 소속 플랜트 (예: "Unit-A")
- state0descriptor: "Open/Close" 또는 유사한 문자열
#### 7.4 v_tag_summary 뷰 테스트
**목표:** 실시간값 + 메타데이터 통합 조회 확인
**단계:**
```sql
-- psql에서 실행
SELECT * FROM v_tag_summary WHERE base_tag = 'xv-6124';
```
**기대 결과:**
| base_tag | pv | op | instate0 | instate1 | instate2 | description | area | state0_descriptor | state1_descriptor | state2_descriptor |
|----------|-----|-----|----------|----------|----------|-------------|------|-------------------|-------------------|-------------------|
| xv-6124 | 1 | 1 | true | false | false | XV-6124 설명 | Unit-A | Open/Close | Remote/Local | Fault/Normal |
#### 7.5 NL2SQL 통합 테스트
**목표:** 자연어 질문으로 디지털 태그 상태 조회
**단계:**
1. Text-to-SQL 탭에서 "xv-6124 현재 상태 알려줘" 입력
2. 생성된 SQL이 v_tag_summary를 사용하는지 확인
3. 결과에 instate0~2 값과 state descriptor가 포함되는지 확인
**기대 결과:**
- SQL: `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'`
- 결과 테이블에 상태 비트와 의미 표시됨
### 테스트 체크리스트
- [ ] xv-6124 포인트 realtime_table에 등록됨
- [ ] OPC UA 구독 시 livevalue 업데이트됨
- [ ] 메타데이터 재로드 시 tag_metadata에 저장됨
- [ ] v_tag_summary 뷰에서 통합 조회 가능
- [ ] NL2SQL에서 자연어 질문으로 상태 조회 가능
- [ ] 포인트빌더에서 최대 10개 태그 선택 가능

View File

@@ -0,0 +1,188 @@
# pvC UA EnumValueType 메타데이터 저장 최적화 제안서
> 작성일: 2026-05-07
> 상태: 분석 중
> 목적: xv, p-xxxxx 등 디지털 장비 태그의 pv 값 (i=7594) 저장 방식 최적화
---
## 1. 현재 pv 값 저장 방식 분석
### 1.1 pvC UA EnumValueType (i=7594) 데이터 흐름
디지털 장비(xv 밸브, p-xxxxx 펌프 등)의 pv(Output) 값은 pvC UA 표준 데이터 타입인 `EnumValueType (i=7594)`를 사용한다. 이 값은 pvC UA 서버에서 읽을 때 `{정수 | DisplayName | }` 형식의 문자열로 반환된다.
### 1.2 실제 DB 저장 예시
`realtime_table`에 pv 값이 저장되는 실제 데이터:
| tagname | livevalue | node_id |
|---------|-----------|---------|
| xv-3202.pv | `{0 \| L-MID \| }` | ns=1;s=sinamserver:xv-3202.pv |
| xv-3202_hs.pv | `{0 \| CLOSE \| }` | ns=1;s=sinamserver:xv-3202_hs.pv |
| xv-360.pv | `{0 \| MID \| }` | ns=1;s=sinamserver:xv-360.pv |
| p-10602a.pv | `{0 \| L-STpv \| }` | ns=1;s=sinamserver:p-10602a.pv |
| p-2816a.pv | `{0 \| L-STpv \| }` | ns=1;s=sinamserver:p-2816a.pv |
| p-203_hs.pv | `{0 \| STpv \| }` | ns=1;s=sinamserver:p-203_hs.pv |
### 1.3 pv 값의 구조
pv 값 `{0 \| L-STpv \| }`는 다음 3가지 정보를 포함:
- **정수 코드 (0)**: 실제 상태 코드 (0~7 범위)
- **DisplayName (L-STpv)**: 화면에 표시될 상태 이름
- **빈 설명 영역**: 현재 사용되지 않음
### 1.4 각 태그당 존재하는 속성 수
`node_map_master`에서 한 태그(예: p-10214)당 **426개 속성**이 존재한다. 주요 속성:
- 핵심 데이터: pv, pv, sp, md
- 상태 플래그: instate0~instate7
- 상태 설명: state0descriptor~state7descriptor
- 메타데이터: desc, area
- 품질/시간: pvquality, pvlastscannedtime 등
- 설정값: state0ondelay, alarmpriority 등
- 기타: equipprpverties, templateid, guid 등
---
## 2. tag_metadata 테이블 분석
### 2.1 현재 tag_metadata 테이블 스키마
```sql
CREATE TABLE tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
```
### 2.2 MetadataLoaderService가 로드하는 속성
| 속성 | 데이터 타입 | 설명 |
|------|------------|------|
| desc | String | 태그 설명 |
| area | i=7594 | 소속 플랜트/Asset (EnumValueType) |
| state0descriptor | String | 상태 0 설명 |
| state1descriptor | String | 상태 1 설명 |
| state2descriptor | String | 상태 2 설명 |
| state3descriptor | String | 상태 3 설명 |
| state4descriptor | String | 상태 4 설명 |
| state5descriptor | String | 상태 5 설명 |
| state6descriptor | String | 상태 6 설명 |
| state7descriptor | String | 상태 7 설명 |
### 2.3 현재 tag_metadata 테이블 상태
**테이블이 비어 있음** — 메타데이터 로드가 아직 실행되지 않았거나 실패한 상태.
### 2.4 문제점: pv 값과 state descriptor의 중복
pv 값 `{0 | L-STpv | }` 자체가 **상태 코드 + DisplayName**을 포함하고 있다.
즉, `state0descriptor` 등의 값을 별도로 저장할 필요 없이 pv 값에서 파싱하면 된다.
| 데이터 소스 | 포함 정보 |
|------------|----------|
| `realtime_table`의 pv livevalue | `{0 \| L-STpv \| }` → 코드=0, 이름=L-STpv |
| `tag_metadata`의 state0descriptor | "L-STpv" (pv 값과 동일한 정보) |
**결론: pv 값에 이미 state descriptor 정보가 포함되어 있으므로 중복 저장.**
---
## 3. 메타데이터 컬럼 유지 여부 제안
### 3.1 컬럼 분류
tag_metadata 테이블에서 각 속성을 3가지 카테고리로 분류:
| 카테고리 | 속성 | 필요성 | 이유 |
|----------|------|--------|------|
| **필수 유지** | desc | ⭐ 필수 | 태그 설명은 pv 값에 포함되지 않음 |
| **필수 유지** | area | ⭐ 필수 | 소속 플랜트 정보는 pv 값에 포함되지 않음 |
| **불필요** | state0~7descriptor | ❌ 중복 | pv 값 `{코드 \| 이름 \| }`에 이미 포함 |
### 3.2 제안: desc, area만 저장
**tag_metadata 테이블에는 `desc`와 `area`만 저장하고, `state0~7descriptor`는 저장하지 말 것.**
#### 근거
1. **정보 중복 제거**: pv 값 `{0 | L-STpv | }`에서 상태 이름 "L-STpv"을 파싱 가능
2. **저장소 절감**: 태그당 8개 state descriptor 행 제거 (10개 → 2개 속성)
3. **로드 시간 단축**: pvC UA에서 읽어야 할 속성 10개 → 2개로 감소
4. **데이터 일관성**: state descriptor는 pvC 서버 설정에 따라 변경될 수 있음. pv 값이 실시간 상태이므로 더 신뢰할 수 있음
#### pv 값 파싱 방법
```
pv 값: "{0 | L-STpv | }"
파싱: 코드=0, 상태이름="L-STpv"
pv 값: "{0 | MID | }"
파싱: 코드=0, 상태이름="MID"
```
프론트엔드에서 pv 값 표시 시 `{``|`를 파싱하여 상태 이름만 표시하면 됨.
### 3.3 수정이 필요한 코드
| 파일 | 수정 내용 |
|------|----------|
| `MetadataLoaderService.cs` | `MetaAttributes` 배열에서 state0~7descriptor 제거 |
| `ExperionDbContext.cs` | `v_tag_summary` 뷰에서 state descriptor JOIN 제거 |
| `app.js` | pv 값 표시 시 파싱 로직 추가 |
### 3.4 tag_metadata 테이블의 node_id, loaded_at 컬럼 유지 여부
| 컬럼 | 유지 제안 | 이유 |
|------|----------|------|
| node_id | 유지 | pvC UA 노드 ID는 재로드 시 변경될 수 있으므로 추적 필요 |
| loaded_at | 유지 | 메타데이터 갱신 시점 확인에 유용 |
**결론: node_id와 loaded_at은 유지하는 것이 좋음.**
---
## 4. 요약 및 다음 단계
### 4.1 핵심 발견
1. **pv 값 저장 방식**: `realtime_table``livevalue``{코드 | DisplayName | }` 형식으로 저장됨
2. **정보 중복**: pv 값에 state descriptor 정보가 이미 포함되어 있음
3. **tag_metadata 비어있음**: 메타데이터 로드가 아직 실행되지 않은 상태
4. **속성 과다**: 각 태그당 426개 속성 중 실제로 필요한 것은 desc, area 2개뿐
### 4.2 최종 제안
| 항목 | 결정 |
|------|------|
| tag_metadata 저장 속성 | desc, area 만 |
| state0~7descriptor | 저장하지 않음 (pv 값에서 파싱) |
| node_id 컬럼 | 유지 (추적용) |
| loaded_at 컬럼 | 유지 (갱신 시점 확인) |
### 4.3 기대 효과
- **pvC UA 읽기 요청 80% 감소**: 10개 속성 → 2개 속성
- **tag_metadata 행 80% 감소**: 태그당 10행 → 2행
- **메타데이터 로드 시간 단축**: 네트워크 트래픽과 DB 삽입량 대폭 감소
- **데이터 일관성 향상**: pv 값이 실시간 상태이므로 더 신뢰할 수 있음
### 4.4 다음 단계
1. `MetadataLoaderService.cs` 수정: state descriptor 제거
2. `ExperionDbContext.cs` 수정: v_tag_summary 뷰 단순화
3. 프론트엔드 pv 값 파싱 로직 추가
4. 메타데이터 로드 테스트 실행
5. P-XXXXX, XV-XXXXX.pv 등의 상태변화를 감지하여 EVENT TABLE에 기록하여 LLM의 플랜트 상태보고에 활용하게 하고, NL2SQL에서도 언제부터 언제까지의 이벤트 정보를 표시 할 수있게 하자(UI 'Event 조회' 별도 페이지 만들어서 )
EVENT TABLE은 컬럼 TimeStamp, Tagname, Value, StateText 를 p-xxxxx, xv-xxxxx등의 pv 값{ 값 | 상태 텍스트 | }에서 가져오고 값의 변경이 일어났을때만 , event history 테이블에 기록.
event 테이블을 아날로그값에도 적용할지는 나중에 생각하고 일단 Brain Storming 기록만
- ficq-6113 : pid controller 류 들의 Manual : pv 기준, Auto : sp 기준으로 값이 변경되면 event 히스토리에 변경값 기록 이전값->변경값 두개다 기록
- Auto <-> Manual 전환도 기록 : ficq-6113.md 기준 -> 실시간 테이블 스키마 변경
- user 전환 (근무교대 시프트 기록) ---> 이건 아직 파악 못했슴 (node_map_master 에 정보 없슴)

View File

@@ -0,0 +1,443 @@
# Enum Metadata 최적화 - 코딩 계획
> 작성일: 2026-05-08
> 상태: 진행 중
> 기반 문서: [`plans/enum-metadata-optimization.md`](plans/enum-metadata-optimization.md)
> 목적: `state0~7descriptor` 제거, `desc`/`area`만 유지, pv 값 파싱 로직 추가
---
## 작업 Todo 리스트
각 단계는 독립적으로 완료 여부를 추적할 수 있다. 체크박스를 사용하여 진행 상황을 기록한다.
- [ ] STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
- [ ] STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거
- [ ] STEP 3 — `MetadataLoaderService.cs`: 빌드 검증
- [ ] STEP 4 — `ExperionDbContext.cs`: `v_tag_summary` 뷰에서 state descriptor JOIN 제거
- [ ] STEP 5 — `ExperionDbContext.cs`: 빌드 검증
- [ ] STEP 6 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가
- [ ] STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
- [ ] STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
- [ ] STEP 9 — git 커밋 및 정리
---
## 변경 대상 파일 목록
| # | 파일 | 변경 내용 | 영향 범위 |
|---|------|-----------|-----------|
| 1 | `src/Infrastructure/OpcUa/MetadataLoaderService.cs` | `MetaAttributes` 배열 수정 | 메타데이터 로딩 |
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | `v_tag_summary` 뷰 단순화 | DB 뷰 |
| 3 | `src/Web/wwwroot/js/app.js` | pv 파싱 헬퍼 + 표시 로직 변경 | 프론트엔드 UI |
---
## 각 단계 상세 계획
---
### STEP 1 — 백업: 수정 대상 파일들을 `.rooBackup/`에 백업
**목적**: 수정 전 원본 보존. 실패 시 복원 가능.
**실행 명령**:
```bash
TIMESTAMP=$(date +%Y%m%d%H%M)
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database
mkdir -p .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js
cp src/Infrastructure/OpcUa/MetadataLoaderService.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/OpcUa/
cp src/Infrastructure/Database/ExperionDbContext.cs .rooBackup/enum-opt-$TIMESTAMP/src/Infrastructure/Database/
cp src/Web/wwwroot/js/app.js .rooBackup/enum-opt-$TIMESTAMP/src/Web/wwwroot/js/
```
**검증 기준**:
- [ ] `.rooBackup/enum-opt-YYYYMMDDHHMM/` 폴더가 생성되고 3개 파일이 복사됨
- [ ] 원본 파일과 백업 파일의 체크섬이 일치함 (`md5sum` 비교)
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.3: 수정이 필요한 코드 — `MetadataLoaderService.cs`, `ExperionDbContext.cs`, `app.js`
---
### STEP 2 — `MetadataLoaderService.cs`: `MetaAttributes` 배열에서 state0~7descriptor 제거
**파일**: [`MetadataLoaderService.cs`](src/Infrastructure/OpcUa/MetadataLoaderService.cs:20)
**변경 위치**: 20~26줄 (`MetaAttributes` 배열 정의)
**변경 전 코드**:
```csharp
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
```
**변경 후 코드**:
```csharp
// 로드할 메타데이터 속성 목록 (state0~7descriptor 제거 — pv 값에서 파싱)
private static readonly string[] MetaAttributes =
{
"desc", "area"
};
```
**diff**:
```diff
-// 로드할 메타데이터 속성 목록
+// 로드할 메타데이터 속성 목록 (state0~7descriptor 제거 — pv 값에서 파싱)
private static readonly string[] MetaAttributes =
{
- "desc", "area",
- "state0descriptor", "state1descriptor", "state2descriptor",
- "state3descriptor", "state4descriptor", "state5descriptor",
- "state6descriptor", "state7descriptor"
+ "desc", "area"
};
```
**변경 이유**:
- pv 값 `{코드 | DisplayName | }`에 이미 state descriptor 정보가 포함되어 있으므로 중복 저장 제거
- OPC UA 읽기 요청 80% 감소 (10개 속성 → 2개 속성)
- tag_metadata 행 80% 감소 (태그당 10행 → 2행)
**영향 분석**:
- `LoadMetadataAsync()` 메서드: `MetaAttributes.Contains(n.Name)`으로 `node_map_master`에서 필터링하므로 state descriptor 노드는 더 이상 조회되지 않음
- `ReadTagsAsync()`: 8개 menos의 nodeId로 배치 읽기 → 네트워크 트래픽 감소
- UPSERT 쿼리: 8개 menos의 행 삽입 → DB 부하 감소
**검증 기준**:
- [ ] `MetaAttributes` 배열이 `["desc", "area"]` 두 개만 포함
- [ ] 컴파일 오류 없음
- [ ] `LoadMetadataAsync()` 메서드의 로직 변경 없이 배열 변경만으로 동작
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: desc, area만 저장
- 섹션 3.3: `MetadataLoaderService.cs` 수정 — `MetaAttributes` 배열에서 state0~7descriptor 제거
- 섹션 4.2: tag_metadata 저장 속성 — desc, area 만
---
### STEP 3 — `MetadataLoaderService.cs` 변경 후 빌드 검증
**목적**: STEP 2 변경이 컴파일 오류 없이 통과하는지 확인
**실행 명령**:
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
**검증 기준**:
- [ ] 빌드 성공 (exit code 0)
- [ ] 경고 없음 (또는 기존 경고만)
- [ ] `MetaAttributes` 배열 참조하는 다른 코드가 없는지 확인
**실패 시 대응**:
- 컴파일 오류가 발생하면 오류 메시지를 읽고 원인 분석
- `MetaAttributes` 길이에 의존하는 코드가 있으면 해당 코드도 수정
---
### STEP 4 — `ExperionDbContext.cs`: `v_tag_summary` 뷰에서 state descriptor JOIN 제거
**파일**: [`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:302)
**변경 위치**: 302~330줄 (`v_tag_summary` 뷰 생성 SQL)
**변경 전 코드** (315~329줄):
```sql
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
```
**변경 후 코드**:
```sql
desc_md.value AS description,
area_md.value AS area
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
```
**diff**:
```diff
desc_md.value AS description,
area_md.value AS area,
- s0d_md.value AS state0_descriptor,
- s1d_md.value AS state1_descriptor,
- s2d_md.value AS state2_descriptor
+ area_md.value AS area
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
- LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
- LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
- LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
**변경 이유**:
- state0~2descriptor가 더 이상 tag_metadata에 저장되지 않으므로 JOIN 제거
- 뷰 조회 성능 향상 (3개 LEFT JOIN 제거)
**영향 분석**:
- `v_tag_summary` 뷰를 조회하는 코드가 `state0_descriptor`, `state1_descriptor`, `state2_descriptor` 컬럼을 참조하면 NULL 반환
- 이 뷰를 사용하는 곳이 있는지 검색 필요 (현재는 DB 초기화 시에만 사용)
**검증 기준**:
- [ ] `v_tag_summary` 뷰 SQL에서 state descriptor 관련 JOIN 3개 제거됨
- [ ] `area_md.value AS area` 뒤 쉼표 제거 (마지막 SELECT 컬럼)
- [ ] SQL 문법 오류 없음
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.3: `ExperionDbContext.cs` 수정 — v_tag_summary 뷰에서 state descriptor JOIN 제거
---
### STEP 5 — `ExperionDbContext.cs` 변경 후 빌드 검증
**목적**: STEP 4 변경이 컴파일 오류 없이 통과하는지 확인
**실행 명령**:
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
**검증 기준**:
- [ ] 빌드 성공 (exit code 0)
- [ ] SQL 문자열 문법 오류 없음 (raw string literal 안에서 SQL 구문 확인)
- [ ] 쉼표 누락/과잉 없음
**실패 시 대응**:
- SQL 구문 오류가 발생하면 SELECT 컬럼 목록의 쉼표 확인
- `area_md.value AS area`가 마지막 컬럼이므로 쉼표 제거했는지 확인
---
### STEP 6 — `app.js`: pv 값 파싱 헬퍼 함수 `parseEnumPv()` 추가
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:2126)
**삽입 위치**: `fmtVal()` 함수 바로 아래 (2132줄 이후)
**추가할 코드**:
```javascript
/**
* OPC UA EnumValueType pv 값 파싱
* "{0 | L-STpv | }" → "L-STpv"
* "{0 | MID | }" → "MID"
* 일반 값은 그대로 반환
*/
function parseEnumPv(v) {
if (v == null) return v;
const s = String(v);
// "{코드 | DisplayName | }" 패턴 매칭
const m = s.match(/^\{\s*\d+\s*\|\s*([^|]+?)\s*\|\s*\}$/);
return m ? m[1].trim() : s;
}
```
**삽입 위치 상세**:
- 2132줄 (`}``fmtVal` 함수 종료) 바로 다음에 삽입
- 기존 `fmtVal` 함수는 변경하지 않음
**기능 설명**:
- 입력: pv 값 문자열 (예: `"{0 | L-STpv | }"`)
- 출력: DisplayName 부분만 (예: `"L-STpv"`)
- EnumValueType 형식이 아닌 일반 값은 그대로 반환
**정규식 설명**:
- `^\{``{`로 시작
- `\s*\d+\s*` — 정수 코드 (공백 허용)
- `\|\s*``|` 구분자
- `([^|]+?)` — DisplayName (첫 번째 캡처 그룹)
- `\s*\|\s*\}$``| }`로 끝남
**검증 기준**:
- [ ] `parseEnumPv("{0 | L-STpv | }")``"L-STpv"` 반환
- [ ] `parseEnumPv("{0 | MID | }")``"MID"` 반환
- [ ] `parseEnumPv("123.45")``"123.45"` 반환 (변경 없음)
- [ ] `parseEnumPv(null)``null` 반환
- [ ] 브라우저 콘솔에서 수동 테스트 가능
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: pv 값 파싱 방법 — `{코드 | 이름 | }`에서 DisplayName 추출
- 섹션 3.3: `app.js` — pv 값 표시 시 파싱 로직 추가
---
### STEP 7 — `app.js`: pv 값 표시 관련 코드 모두 `parseEnumPv()` 적용
**파일**: [`app.js`](src/Web/wwwroot/js/app.js:633)
**변경 위치**: `pbRender()` 함수 내 liveValue 표시 부분 (633줄)
**변경 전 코드** (633줄):
```javascript
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(p.liveValue))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**변경 후 코드**:
```javascript
<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**diff**:
```diff
-<td class="val">${p?.liveValue != null ? esc(String(fmtVal(p.liveValue))) : '<span style="color:var(--t3)">—</span>'}</td>
+<td class="val">${p?.liveValue != null ? esc(String(fmtVal(parseEnumPv(p.liveValue)))) : '<span style="color:var(--t3)">—</span>'}</td>
```
**변경 이유**:
- `liveValue`가 EnumValueType 형식(`{0 | L-STpv | }`)이면 DisplayName만 표시
- 일반 숫자 값은 `parseEnumPv()`가 그대로 반환하므로 영향 없음
- `fmtVal()`은 숫자 포맷팅만 담당, `parseEnumPv()`는 EnumValueType 파싱만 담당 (단일 책임)
**다른 적용 위치 확인**:
- `fmtVal()`을 사용하는 모든 곳을 검색하여 pv 값 표시에 사용되는 곳에 `parseEnumPv()` 적용
- `t2sRenderTable()` (1606줄): `fmtVal(val)` — 시계열 데이터이므로 변경 불필요 (아날로그 값 중심)
- `histQuery()` 결과 렌더링 (956줄): `fmtVal(raw)` — 이력 데이터이므로 변경 불필요
**검증 기준**:
- [ ] 포인트빌더 테이블에서 xv-3202.pv 값이 `"{0 | L-MID | }"` 대신 `"L-MID"`로 표시됨
- [ ] 일반 숫자 값(예: ficq-6113.pv = "123.45")은 여전히 `"123.45"`로 표시됨
- [ ] 브라우저 콘솔에서 JS 오류 없음
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 3.2: 프론트엔드에서 pv 값 표시 시 `{``|`를 파싱하여 상태 이름만 표시
---
### STEP 8 — End-to-End 검증: 전체 빌드 + UI 테스트
**목적**: 모든 변경 사항이 함께 동작하는지 최종 확인
**실행 명령**:
```bash
# 1. 전체 솔루션 빌드
dotnet build ExperionCrawler.sln -v q
# 2. 애플리케이션 시작
dotnet run --project src/Web/ExperionCrawler.csproj
```
**검증 체크리스트**:
#### 빌드 검증
- [ ] `dotnet build` 성공 (exit code 0)
- [ ] 컴파일 오류 0개
- [ ] 경고 수 증가 없음
#### 백엔드 검증
- [ ] 애플리케이션 시작 시 DB 초기화 성공
- [ ] `v_tag_summary` 뷰 생성 성공 (state descriptor JOIN 없음)
- [ ] 메타데이터 로드 시 `desc`, `area`만 조회됨 (로그 확인)
- [ ] `tag_metadata` 테이블에 state0~7descriptor 행 없음
#### 프론트엔드 검증
- [ ] 브라우저 콘솔 JS 오류 없음
- [ ] 포인트빌더 테이블에서 digital 태그 pv 값이 DisplayName만 표시됨
- 예: `xv-3202.pv``"L-MID"` (기존: `"{0 | L-MID | }"`)
- [ ] 아날로그 태그 pv 값은 정상 표시됨
- 예: `ficq-6113.pv``"123.45"`
- [ ] 메타데이터 조회 시 `desc`, `area`만 반환됨
#### 성능 검증
- [ ] 메타데이터 로드 시간 감소 확인 (기존 대비)
- [ ] `tag_metadata` 테이블 행 수 감소 확인 (기존 10행/태그 → 2행/태그)
**enum-metadata-optimization.md 규칙 매핑**:
- 섹션 4.3: 기대 효과 — OPC UA 읽기 요청 80% 감소, tag_metadata 행 80% 감소
- 섹션 4.4: 다음 단계 — 메타데이터 로드 테스트 실행
---
### STEP 9 — git 커밋 및 정리
**목적**: 변경 사항을 커밋하고 작업 기록 남기기
**커밋 전략**: 각 파일 변경을 별도 커밋으로 관리
```bash
# 1. MetadataLoaderService.cs 커밋
git add src/Infrastructure/OpcUa/MetadataLoaderService.cs
git commit -m "feat: MetaAttributes에서 state0~7descriptor 제거 (pv 값 파싱으로 대체)"
# 2. ExperionDbContext.cs 커밋
git add src/Infrastructure/Database/ExperionDbContext.cs
git commit -m "feat: v_tag_summary 뷰에서 state descriptor JOIN 제거"
# 3. app.js 커밋
git add src/Web/wwwroot/js/app.js
git commit -m "feat: pv 값 파싱 헬퍼 parseEnumPv() 추가, 포인트빌더 테이블 적용"
# 4. 계획 문서 커밋
git add plans/enum-metadata-optimize-coding-plan.md
git commit -m "docs: enum metadata 최적화 코딩 계획 작성"
```
**검증 기준**:
- [ ] 각 커밋 메시지가 변경 내용을 명확히 설명함
- [ ] `git log`에서 커밋 순서가 논리적임
- [ ] `.rooBackup/` 폴더에 gitignore 적용되어 있음 (백업 파일 커밋 제외)
---
## 부록: rollback 절차
작업 중 문제가 발생하면 백업 파일로 복원:
```bash
TIMESTAMP=$(ls -d .rooBackup/enum-opt-* | tail -1 | xargs basename)
cp .rooBackup/$TIMESTAMP/src/Infrastructure/OpcUa/MetadataLoaderService.cs src/Infrastructure/OpcUa/
cp .rooBackup/$TIMESTAMP/src/Infrastructure/Database/ExperionDbContext.cs src/Infrastructure/Database/
cp .rooBackup/$TIMESTAMP/src/Web/wwwroot/js/app.js src/Web/wwwroot/js/
```
---
## 검증 요약
각 STEP의 검증 기준을 한눈에 확인:
| STEP | 파일 | 핵심 검증 항목 |
|------|------|----------------|
| 1 | — | 백업 파일 3개 생성됨 |
| 2 | MetadataLoaderService.cs | `MetaAttributes` = `["desc", "area"]` |
| 3 | — | 빌드 성공 |
| 4 | ExperionDbContext.cs | state descriptor JOIN 3개 제거됨 |
| 5 | — | 빌드 성공 |
| 6 | app.js | `parseEnumPv()` 함수 추가됨 |
| 7 | app.js | `pbRender()`에서 `parseEnumPv()` 적용됨 |
| 8 | 전체 | End-to-End 테스트 통과 |
| 9 | — | git 커밋 완료 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
# loop1.cde 파일 분석 보고서
## 1. 파일 기본 정보
| 항목 | 값 |
|------|-----|
| 파일 경로 | `plans/hc900/loop1.cde` |
| 파일 크기 | 593,557 바이트 (~580KB) |
| 파일 형식 | 바이너리 (Honeywell proprietary) |
| 추출 가능한 문자열 | 198 줄 |
## 2. 파일 형식 식별
`.cde` 파일은 **Honeywell Experion HS / Foxboro I/A Series**의 **Control Definition (제어 정의)** 파일입니다.
HC900 컨트롤러의 제어 루프 구성 정보를 포함하는 바이너리 포맷입니다.
### 헤더 구조 (초기 바이트 분석)
```
Offset 0x0000: 0202 2000 4800 0b00 ... (파일 마직 / 버전)
Offset 0x0018: 0001 006c (식별자: 0x6c01)
Offset 0x001C: 0200 532b (Unicode "S+")
Offset 0x0088: 69f7 21e4 69f7 2282 (타임스탬프 또는 체크섬)
Offset 0x0098: 0100 484a 0032 0063 (Unicode "HJ" + "2c")
```
## 3. 추출된 핵심 내용
### 3.1 제어 루프 정보
| 태그명 | 설명 |
|--------|------|
| `PID102` | PID 컨트롤러 블록 이름 |
| `PID102_WSP` | PID 컨트롤러 설정값 (Work Setpoint) |
### 3.2 컨트롤러 구성 요소
추출된 문자열에서 확인된 구성 영역:
| 영역 | 원문 | 설명 |
|------|------|------|
| CONFIG | CONFIG | 컨트롤러 일반 설정 |
| PROFIL | PROFIL | 프로파일 (운전 모드별 설정) |
| RECIPES | RECIPES | 레시피 관리 |
| SCHEDL | SCHEDL | 스케줄러 |
| SEQUEN | SEQUEN | 시퀀서 |
| STORAG | STORAG | 데이터 저장소 |
| FILE | FILE (x19) | 파일 관리 블록 |
### 3.3 기타 설정
| 항목 | 값 |
|------|-----|
| 컨트롤러 | CONTROLLER |
| 프로세스 구성 | Process Configuration#1 |
| 통신 | Custom Modbus Map |
| 파티션 | Undefined Partition |
| 신호 단위 | mA (4-20mA 아날로그 신호) |
## 4. 파일 구조 추정
```
loop1.cde (593,557 bytes)
├── Header (0x000 - 0x0FF) : 파일 마직, 버전, 메타데이터
├── Block Definitions (0x100 - 0x31F) : 블록 정의 영역
├── Tag Index (0x320 - 0x4FF) : 태그 인덱스 (e.(, e.) 등 순차적 인덱스)
├── Configuration Data : CONFIG, PROFIL, RECIPES 등 구성 데이터
├── Controller Properties : CONTROLLER, Process Configuration#1
├── Communication Settings : Custom Modbus Map
└── Padding/Reserved : 나머지 영역
```
## 5. 분석 가능성 평가
### 가능한 분석
- ✅ 파일 형식 식별 (Honeywell Control Definition 파일)
- ✅ 주요 태그명 추출 (PID102, PID102_WSP)
- ✅ 구성 영역 식별 (CONFIG, PROFIL, RECIPES, SCHEDL, SEQUEN, STORAG)
- ✅ 컨트롤러 정보 확인 (Process Configuration#1, Custom Modbus Map)
### 제한사항
-**완전한 구조 파싱 불가**: Honeywell proprietary 바이너리 포맷으로 공식 스펙이 공개되지 않음
-**PID 파라미터 추출 불가**: Kp, Ki, Kd 등 제어 게인 값은 바이너리 인코딩되어 있음
-**블록 연결 관계 파악 불가**: 신호 흐름/와이어링 정보는 구조화되어 있음
-**모든 태그 목록 추출 불가**: 198줄의 문자열만 추출 가능 (전체 데이터의 일부)
## 6. 완전 파싱을 위한 방안
### 옵션 1: Honeywell 공식 도구 사용
- **System Management Application (SMA)**: Honeywell Experion HS 표준 관리 도구
- **Experion PKS Builder**: 제어 루프 구성을 시각적으로 확인 가능
- **CDE Export 기능**: SMA에서 XML/CSV로 내보내기 가능
### 옵션 2: 바이너리 파서 개발
- 파일 구조를 reverse engineering 하여 파서 작성 필요
- 동일한 HC900 시스템에서 추출한 여러 CDE 파일을 비교 분석
- Honeywell 문서 (Experion HS R530 HTM) 에서 힌트 수집
### 옵션 3: OPC UA를 통한 실시간 접근
- 이미 구축된 ExperionCrawler를 통해 OPC UA로 실시간 태그 값 접근
- CDE 파일 대신 OPC UA Address Space에서 메타데이터 조회
- 더 신뢰성 있는 데이터 소스
## 7. 결론
`loop1.cde` 파일은 **Honeywell HC900 컨트롤러의 제어 루프 정의 파일**로, PID102 컨트롤러 블록과 관련 설정을 포함합니다.
바이너리 포맷의 특성상 `strings` 명령어로 일부 텍스트만 추출 가능하고, 완전한 파싱은 Honeywell 공식 도구가 필요합니다.
ExperionCrawler 프로젝트에서는 이 파일 대신 **OPC UA를 통한 실시간 데이터 접근**이 더 실용적인 접근 방식입니다.

BIN
plans/hc900/loop1.cde Normal file

Binary file not shown.

View File

@@ -0,0 +1,78 @@
# HC900 Modbus TCP 스캔 시간 분석
## 1. 측정 환경
| 항목 | 값 |
|------|-----|
| 대상 | HC900 C3 컨트롤러 (192.168.0.240) |
| 네트워크 | 100Mbps LAN |
| Ping RTT | 최소 0ms, 최대 1ms, 평균 0ms |
| 바이트 순서 | Big Endian (HC900 기본) |
| 통신 프로토콜 | Modbus TCP (FC03 Read Holding Registers) |
## 2. 데이터 양
| 항목 | 값 |
|------|-----|
| 총 태그 항목 | 2,554개 |
| float 32 (4바이트) | 2,320개 → 4,640 레지스터 |
| unsigned 16 (2바이트) | 234개 → 234 레지스터 |
| **총 레지스터** | **4,874개** |
| 총 바이트 | 9,748 bytes |
| 주소 범위 | 0x0001 ~ 0x7FFF (32,767 스팬) |
| 주소 갭 | 273개 (불연속) |
## 3. Modbus TCP 읽기 요청 수
- FC03 최대 125 레지스터/요청
- 불연속 주소로 인해 **최소 290회** 읽기 요청 필요
### Partition별 읽기 횟수
| Partition | 항목 수 | 주소 스팬 | 읽기 횟수 |
|-----------|---------|----------|----------|
| Loops 1-24 | 960 | 6,079 | 192회 |
| Loops 25-32 | 329 | 1,983 | 65회 |
| Signal Tags 1-1000 | 549 | 1,125 | 11회 |
| Signal Tags 1-4000 | 549 | 1,125 | 11회 |
| Variables 1-600 | 157 | 337 | 9회 |
| Time | 7 | 7 | 1회 |
| Misc. Parameters | 3 | 5 | 1회 |
## 4. 스캔 시간 계산
### 단일 스레드 순차 읽기
| 조건 | RTT | 전송/요청 | 총 시간 | 1초당 스캔 |
|------|-----|----------|---------|-----------|
| **최적** (Ping 그대로) | 0.2ms | 0.028ms | **0.066초** | 15.1회/s |
| **보수** (Ping+처리) | 0.5ms | 0.028ms | **0.153초** | 6.5회/s |
| **안전** (Ping 최대) | 1.0ms | 0.028ms | **0.298초** | 3.4회/s |
### 7개 Partition 병렬 읽기
| RTT | 총 시간 | 비고 |
|-----|---------|------|
| 0.2ms | 43.7ms | Loops 1-24 (192회)가 병목 |
| 0.5ms | 101.3ms | |
| 1.0ms | 197.3ms | |
## 5. 실시간 모니터링 가능 여부
| 샘플링 주기 | RTT=0.2ms | RTT=0.5ms | RTT=1.0ms |
|-------------|-----------|-----------|-----------|
| 10초 | ✓ 여유 99% | ✓ 여유 98% | ✓ 여유 97% |
| 5초 | ✓ 여유 99% | ✓ 여유 97% | ✓ 여유 94% |
| 2초 | ✓ 여유 97% | ✓ 여유 92% | ✓ 여유 85% |
| 1초 | ✓ 여유 93% | ✓ 여유 85% | ✓ 여유 70% |
| 0.5초 | ✓ 여유 87% | ✓ 여유 69% | ✓ 여유 40% |
| 100ms | ✓ 여유 34% | ✗ 불가 | ✗ 불가 |
## 6. 결론
- **290회 Modbus TCP 읽기**로 HC900 C3 컨트롤러의 전체 데이터 수집 가능
- 최적 조건에서 **0.066초**, 안전 조건에서 **0.298초**
- **1초 단위 실시간 모니터링 충분히 가능** (여유 70~93%)
- 100ms 단위는 최적 조건(RTT 0.2ms)에서만 가능
- Big Endian는 HC900 기본이므로 바이트 변환 불필요
- TCP keep-alive 필수 (handshake 오버헤드 제거)

View File

@@ -0,0 +1,464 @@
# 실시간 태그 확장 설계안
## 1. 배경
현재 ExperionCrawler는 아날로그 태그의 `.pv`, `.sp`, `.op`, `.qv.value``realtime_table`에 저장하고 있음.
디지털 장비(Pump, XV)의 상태 정보와 모든 태그의 `.desc`, `.area` 메타데이터를 추가하고자 함.
## 2. 현재 시스템 아키텍처
```
Experion HS R530 OPC Server
↓ (1. 크롤링 - 전체 노드 스캔)
CSV 파일 저장
↓ (2. CSV 로드)
node_map_master (530,080 행)
↓ (3. UI에서 태그+속성 선택 - 최대 8개)
realtime_table (등록된 태그만)
↓ (4. OPC UA 구독)
livevalue 실시간 업데이트
↓ (5. 히스토리 저장)
history_table
```
### node_map_master 구조 특징
- `name`: 속성명만 (`pv`, `sp`, `desc`, `area`, `instate0` 등)
- `node_id`: 전체 경로 (`ns=1;s=sinamserver:xv-6124.pv`)
- 상위 Object (`xv-6124`)와 하위 속성 (`pv`)이 별도 행으로 존재
## 3. 추가할 태그
### 3.1 아날로그 태그 (기존 확장)
| 태그 | 데이터 타입 | 설명 |
|------|------------|------|
| `ficq-6101.area` | Enum(i=7594) | 소속 플랜트/Asset |
> 참고: ficq-6101은 `.desc` 속성이 없음 (DB 확인 결과)
### 3.2 디지털 태그 (신규 추가) (0~7까지 모두 추가)
| 태그 | 데이터 타입 | 설명 |
|------|------------|------|
| `xv-6124.pv` | Int32 (BCD 조합값) | 프로세스 값 |
| `xv-6124.op` | Int32 | 출력값 |
| `xv-6124.desc` | String | 장비 설명 |
| `xv-6124.area` | Enum(i=7594) | 소속 플랜트/Asset |
| `xv-6124.instate0` | Boolean | 상태 비트 0 (현재 값) |
| `xv-6124.instate1` | Boolean | 상태 비트 1 (현재 값) |
| `xv-6124.instate2` | Boolean | 상태 비트 2 (현재 값) |
| `xv-6124.state0descriptor` | String | 비트 0 의미 (예: "Run/Stop") |
| `xv-6124.state1descriptor` | String | 비트 1 의미 (예: "Remote/Local") |
| `xv-6124.state2descriptor` | String | 비트 2 의미 (예: "Trip/Normal") |
> **핵심 발견:** Experion HS에 `state0descriptor`~`state7descriptor`가 이미 존재합니다.
> 이는 사용자가 정의한 BCD 상태 의미를 String으로 저장한 태그로,
> OPC UA에서 구독하면 `realtime_table`에 저장 가능하므로 **별도 테이블 불필요**.
### 3.3 BCD 상태값 해석
`pv`는 HC900 컨트롤러에서 Digital Input을 BCD로 조합하여 32bit Float로 전송.
**직접 `pv` 값을 파싱하지 않고 `instate0~7` Boolean 태그를 사용.**
| 사용 비트 | 현재 프로젝트 예시 (Pump) | 현재 프로젝트 예시 (Valve) |
|-----------|---------------------------|----------------------------|
| instate0 | Run(1)/Stop(0) | Open(1)/Close(0) |
| instate0+1 | Run/Stop + R/L | Open/Close + R/L |
| instate0+1+2 | Run/Stop + R/L + Trip/Normal | Open/Close + R/L + Fault/Normal |
> **중요**: 위 값은 현재 프로젝트에서만 알려진 사용자 정의 값입니다.
> 다른 프로젝트에서는 BCD 상태 의미가 다를 수 있으므로 정형화 불가능합니다.
> 각 장비의 실제 비트 사용 수와 의미는Experion HS에서 사용자 정의됩니다.
## 4. 설계 결정
### 4.1 DB 스키마 변경: **tag_metadata 테이블 추가**
메타데이터(desc, area, state0descriptor~7)는 실시간으로 자주 변경되지 않으므로,
별도 테이블에 저장하고 OPC UA 통신 부담을 줄입니다.
```sql
CREATE TABLE tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL, -- ficq-6101, xv-6124
attribute TEXT NOT NULL, -- desc, area, state0descriptor, ...
value TEXT, -- 실제 값
node_id TEXT, -- OPC UA node_id (재로드용)
loaded_at TIMESTAMP DEFAULT NOW(), -- 마지막 로드 시간
UNIQUE(base_tag, attribute)
);
CREATE INDEX idx_tag_metadata_base ON tag_metadata(base_tag);
```
**데이터 분리 전략:**
| 테이블 | 저장 내용 | OPC UA 구독 |
|--------|----------|-------------|
| `realtime_table` | pv, sp, op, instate0~7 등 **실시간 데이터** | ✅ 지속 구독 |
| `tag_metadata` | desc, area, state0descriptor~7 등 **메타데이터** | ❌ 한 번 로드 |
**장점:**
- OPC UA 통신 부담 대폭 감소 (메타데이터는 한 번만 읽음)
- 수천 개 태그에 대해 desc/area를 실시간 구독하지 않음
- UI에서 "재로드" 버튼으로 필요시 갱신 가능
### 4.2 메타데이터 로드/재로드 시나리오
**초기 로드:**
1. UI에서 태그 선택 후 등록 시, 실시간 태그(pv, sp, op, instate0~7)는 `realtime_table`에 등록
2. 동시에 메타데이터(desc, area, state0descriptor~7)는 OPC UA에서 **한 번 읽어서** `tag_metadata` 테이블 저장
3. 읽은 메타데이터 정보로 `node_map_master`도 동시 갱신
**재로드 (UI 버튼):**
1. UI에서 "메타데이터 재로드" 버튼 클릭
2. Backend가 OPC UA 서버에 직접 연결
3. 선택된 태그(또는 전체 등록 태그)의 메타데이터 재조회
4. `tag_metadata` 테이블 UPDATE + `node_map_master` 갱신
```
UI 재로드 버튼
Backend API: POST /api/tags/metadata/reload
OPC UA Read (desc, area, state0descriptor~7)
tag_metadata UPDATE
node_map_master UPDATE (UPSERT)
Response: 갱신된 태그 수
```
### 4.3 SQL 뷰 추가
`realtime_table` + `tag_metadata`를 JOIN하여 태그별 속성을 한눈에 보기 위한 뷰:
```sql
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor';
```
### 4.4 UI 변경
| 항목 | 현재 | 변경 |
|------|------|------|
| 최대 선택 태그 수 | 8개 | 10개 |
| 태그 검색 | name 기준 | name + node_id 기준 |
| 메타데이터 재로드 | 없음 | "메타데이터 재로드" 버튼 추가 |
| 속성 미리보기 | 없음 | 선택 시 하위 속성 표시 (선택사항) |
## 5. 변경 사항 요약
### 5.1 Database
- **신규 테이블**: `tag_metadata` (base_tag, attribute, value, node_id, loaded_at)
- **신규 뷰**: `v_tag_summary` (realtime_table + tag_metadata JOIN)
### 5.2 Backend (`src/Infrastructure/OpcUa/`)
- **신규 서비스**: `MetadataLoaderService`
- `LoadMetadataAsync(base_tags)` : OPC UA에서 desc, area, state0descriptor~7 읽어서 `tag_metadata` 저장
- `ReloadMetadataAsync(base_tags)` : 재로드 시 OPC UA 재조회 + `tag_metadata` UPDATE + `node_map_master` UPSERT
- **신규 API**: `POST /api/tags/metadata/reload`
### 5.3 Frontend (`src/Web/wwwroot/js/app.js`)
- 태그 선택 최대 수 8 → 10개로 변경
- "메타데이터 재로드" 버튼 추가
- (선택사항) 상위 Object 선택 시 하위 속성 자동 표시
### 5.4 Entity/DbContext
- **신규 Entity**: `TagMetadata` class
- **DbContext**: `DbSet<TagMetadata>` 추가, `OnModelCreating` 설정 추가
### 5.5 사용자 작업
- UI에서 새로운 태그(Pump, XV)와 속성(pv, op, instate0~2) 선택하여 등록
- 메타데이터(desc, area, state descriptor)는 자동 로드
- 필요시 "메타데이터 재로드" 버튼으로 갱신
## 6. 대규모 리팩토링이 필요한 시나리오 (향후 고려사항)
현재 설계안은 기존 `realtime_table` 구조를 유지하므로 저위험입니다.
하지만 아래 기능들을 추가하려면 **DB 스키마 변경 + 코드 리팩토링**이 필요합니다.
### 6.1 자동 속성 제안 기능
**문제:** 현재 UI에서 각 태그의 속성(pv, sp, op, desc, area, instate0~2)을 수동으로 선택해야 함.
Pump/XV 같은 디지털 장비는 7개 이상의 속성을 선택해야 하므로 번거로움.
**해결 방안:**
- 상위 Object (예: `xv-6124`) 선택 시, node_map_master에서 하위 속성을 자동으로 조회하여 제안
- 장비 타입에 따라 기본 속성 조합 제안:
- 아날로그 (ficq-6101): `.pv`, `.sp`, `.op`, `.area`
- 디지털 XV (xv-6124): `.pv`, `.op`, `.desc`, `.area`, `.instate0`, `.instate1`, `.instate2`
- Pump (p-6102): `.pv`, `.op`, `.desc`, `.area`, `.instate0`, `.instate1`, `.instate2`
**변경 범위:**
- Frontend: 상위 Object 선택 시 하위 속성 자동 로딩 UI
- Backend: `/api/nodemap/children?parent=xv-6124` API 추가
- node_map_master에서 `node_id LIKE '%xv-6124.%'`로 하위 속성 조회
**리팩토링 위험:** 낮음 (기존 API 추가만 필요)
### 6.2 tag_registry 테이블 도입
**문제:** BCD 상태 의미(instate0=Run/Stop, instate1=R/L 등)는 사용자 정의이므로
시스템에서 자동으로 해석할 수 없음. Text-to-SQL에서 "pump-6102 상태 알려줘" 같은
자연어 질문에 의미 있는 답변을 제공하려면 상태 의미 정보가 필요함.
**해결 방안:**
```sql
CREATE TABLE tag_registry (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL UNIQUE, -- xv-6124, p-6102
equipment_type TEXT, -- pump, xv, ai, ao, fi, ti
bit_count INT DEFAULT 1, -- 사용하는 instate 비트 수 (1~3)
state0_meaning TEXT, -- Run/Stop, Open/Close
state1_meaning TEXT, -- Remote/Local
state2_meaning TEXT, -- Trip/Normal, Fault/Normal
area TEXT, -- 소속 플랜트/Asset
description TEXT, -- 장비 설명
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**활용 시나리오:**
- BCD 상태 의미 저장: 사용자가 UI에서 상태 의미 정의 → DB 저장
- 자동 속성 제안: `equipment_type`에 따라 기본 속성 조합 제안
- Text-to-SQL: "pump-6102 현재 상태" → instate0~2를 Boolean으로 읽어서 상태 의미와 매핑하여 "Run, Remote, Normal" 표시
- P&ID 매핑: P&ID에서 추출한 장비와 Experion 태그를 `tag_registry`를 통해 연결
**리팩토링 위험:** 중간 (새 테이블 + Entity + Service 추가)
### 6.3 장비 타입별 다른 처리 로직
**문제:** 현재 `realtime_table`은 모든 태그를 동일하게 처리.
하지만 장비 타입에 따라 다른 처리가 필요할 수 있음:
- 아날로그: pv/sp/op를 숫자로 표시, 단위 표시
- 디지털 XV: instate0~2를 Boolean으로 읽어서 "Open/Close", "R/L"로 표시
- Pump: instate0~2를 Boolean으로 읽어서 "Run/Stop", "R/L", "Trip/Normal"로 표시
- 알람: alarmflags, alarmvalue를 파싱해서 알람 상태 표시
**해결 방안:**
- `tag_registry.equipment_type`에 따라 UI에서 다른 컴포넌트 렌더링
- Backend에서 장비 타입별 상태 해석 서비스 추가
- BCD 상태값을 Boolean 배열로 파싱하는 유틸리티 함수
**변경 범위:**
- Frontend: 장비 타입별 상태 표시 컴포넌트
- Backend: `EquipmentStateService` 추가 (BCD → Boolean 배열 → 상태 문자열)
- `tag_registry` 테이블과 연동
**리팩토링 위험:** 높음 (Frontend 컴포넌트 + Backend 서비스 + DB 연동)
### 6.4 desc/area 별도 테이블
**문제:** desc/area는 자주 변경되지 않지만, 현재는 `realtime_table`에 실시간 데이터로 저장.
수천 개의 태그에 대해 desc/area를 OPC UA에서 계속 구독하면 불필요한 트래픽 발생.
**해결 방안:**
```sql
CREATE TABLE tag_metadata (
tagname TEXT PRIMARY KEY, -- ficq-6101.area, xv-6124.desc
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
```
- 초기 로드 시 OPC UA에서 desc/area 한 번 읽어서 저장
- 변경 감지: 주기적 폴링 또는 이벤트 기반 업데이트
- UI 조회 시 `tag_metadata`에서 읽되, 변경 의심 시 OPC UA에서 재조회
**리팩토링 위험:** 중간 (새 테이블 + 폴링 서비스 추가)
## 7. 권장 진행 순서
### Phase 1: 기본 확장 (현재 설계안)
1. **UI 변경**: 태그 선택 최대 수 8 → 10개
2. **테스트**: xv-6124의 pv, op, desc, area, instate0~2 등록 및 OPC UA 구독 확인
3. **SQL 뷰**: `v_tag_summary` 생성 (선택사항)
### Phase 2: 자동화 (향후)
4. **자동 속성 제안**: 상위 Object 선택 시 하위 속성 자동 제안
5. **tag_registry 테이블**: BCD 상태 의미 저장, 장비 타입 관리
### Phase 3: 지능형 처리 (장기)
6. **장비 타입별 처리**: BCD 상태 해석, UI 컴포넌트별 렌더링
7. **desc/area 최적화**: 별도 테이블 + 변경 감지 폴링
8. **Text-to-SQL 연동**: 자연어 질문 → BCD 상태 해석 → 의미 있는 답변
## 8. NL2SQL 변경 사항
현재 NL2SQL worker(`mcp-server/worker/nl2sql_worker.py`)는 `history_table``realtime_table`만 인식합니다.
새로운 테이블과 뷰, 태그 타입을 인식하도록 `DB_SCHEMA`를 업데이트해야 합니다.
### 8.1 DB_SCHEMA 업데이트 필요 항목
**추가할 테이블 정의:**
```
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간 + 메타데이터 통합)
base_tag TEXT - 기본 태그명
pv TEXT - 현재 프로세스 값
sp TEXT - 설정값
op TEXT - 출력값
instate0 TEXT - 상태 비트 0 (true/false)
instate1 TEXT - 상태 비트 1 (true/false)
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
```
### 8.2 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "ficq-6101의 현재 값과 설명은?" | `SELECT pv, description FROM v_tag_summary WHERE base_tag = 'ficq-6101'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
| "p-6102의 3개 상태 비트와 의미를 보여줘" | `SELECT instate0, instate1, instate2, state0_descriptor, state1_descriptor, state2_descriptor FROM v_tag_summary WHERE base_tag = 'p-6102'` |
### 8.3 변경 파일 목록
| 파일 | 변경 내용 |
|------|----------|
| `mcp-server/worker/nl2sql_worker.py` | `DB_SCHEMA``tag_metadata`, `v_tag_summary` 추가 |
| `mcp-server/server.py` | `_DB_SCHEMA`에 동일하게 추가 (동기용) |
### 8.4 DB_SCHEMA 추가 내용 예시
```
테이블: tag_metadata (태그 메타데이터 - 변경 드묾, OPC UA 폴링으로 갱신)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor'~'state7descriptor')
value TEXT - 메타데이터 값
loaded_at TIMESTAMPTZ - 마지막 로드 시각
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
base_tag, pv, sp, op, instate0~2, description, area, state0~2_descriptor
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double), ficq-6101.area
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명 (예: "Run/Stop")
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
```
## 9. Event & Alarm 테이블 (지능형 가동 상태 보고용)
### 9.1 필요성
"어제 18시부터 6차 플랜트의 가동상태 알려줘" 같은 지능형 보고를 하려면:
- 펌프/밸브 상태 변화 이력
- 알람 발생/해제 시점
- 비상정지(ESHUTDOWN) 발생/해제 시점
- 유량계 적산값(qv.value)으로 생산량 계산
이 정보들은 **실시간 값이 아닌 이벤트 이력**이므로 별도 테이블 필요.
### 9.2 테이블 구조
```sql
CREATE TABLE event_alarm_log (
id BIGSERIAL PRIMARY KEY,
base_tag TEXT NOT NULL, -- xv-6124, p-6102
event_type TEXT NOT NULL, -- state_change, alarm, shutdown
bit_index INT, -- instate0~7 중 어떤 비트 (state_change용)
old_value TEXT, -- 이전 값
new_value TEXT, -- 새 값
state_descriptor TEXT, -- state0descriptor 등 의미
alarm_priority TEXT, -- Urgent/High/Low/Journal
alarm_message TEXT, -- 알람 메시지
occurred_at TIMESTAMPTZ NOT NULL, -- 발생 시각
acknowledged_at TIMESTAMPTZ, -- 확인 시각
source TEXT DEFAULT 'opcua' -- opcua, manual
);
CREATE INDEX idx_eal_base_tag ON event_alarm_log(base_tag);
CREATE INDEX idx_eal_occurred ON event_alarm_log(occurred_at);
CREATE INDEX idx_eal_type ON event_alarm_log(event_type);
```
### 9.3 수집 방법
**방법 1: OPC UA Event Subscription (권장)**
- Experion HS의 OPC UA Event Mechanism 구독
- 알람 발생 시 실시간으로 이벤트 수신 → DB 저장
**방법 2: 폴링 기반 상태 변화 감지**
- instate0~7 값을 주기적 폴링
- 이전 값과 비교하여 변화 감지 → event_alarm_log INSERT
- alarmflags, alarmvalue 변화도 함께 감지
### 9.4 활용 시나리오
| 질문 | 필요한 데이터 |
|------|--------------|
| "p6 플랜트 가동상태 알려줘" | pump instate0 변화 이력 + shutdown 이벤트 |
| "00시에 왜 정지됐어?" | event_alarm_log WHERE event_type='shutdown' |
| "오늘 생산량?" | qv.value 적산 (history_table) |
| "xv-6124 알람 이력" | event_alarm_log WHERE base_tag='xv-6124' |
### 9.5 NL2SQL DB_SCHEMA 추가 내용
```
테이블: event_alarm_log (이벤트/알람 이력)
base_tag TEXT - 기본 태그명
event_type TEXT - state_change, alarm, shutdown
bit_index INT - instate 비트 인덱스 (0~7)
old_value TEXT - 이전 값
new_value TEXT - 새 값
state_descriptor TEXT - 상태 의미 (예: "Trip, Fault")
alarm_priority TEXT - Urgent/High/Low/Journal
occurred_at TIMESTAMPTZ - 발생 시각
area - 그룹명 (플랜트 'p1~10' 에 그룹 알람 정보 찾기 쉬움)
예시: "오늘 p6 플랜트 알람 목록"
SELECT base_tag, event_type, new_value, state_descriptor, alarm_priority, occurred_at
FROM event_alarm_log
WHERE base_tag IN (SELECT base_tag FROM tag_metadata WHERE attribute='area' AND value='p6')
AND occurred_at >= date_trunc('day', NOW())
ORDER BY occurred_at
```
##### future Plan 항목
이 디자인 플랜의 후순위
- 알람 관련 작업 :Experion HS 알람 구성 파악, 알람 테이블 긁어올 가능성 있는지 체크
- WebUI 도입관련 : 진정한 AX도입의 FINAL WORK가 될 것이며, 위의 모든 기능은 최종 AX화를 위한 걸 기본으로 설계되어야 한다.