Files
HC900-Crawler/docs/diagnosis-steam-flow-display.md
windpacer d88784635e docs: 작업지시·진단·아키텍처 설계 문서 추가
온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:12:01 +09:00

19 KiB

진단 체크리스트 — 온도 프로파일 메타 영역을 표 형식으로 재구성 + 유량 표시

규칙: diagnosis-checklist.md의 8단계를 준수. STEP 3(코드 읽기)과 STEP 6(교차 검증)을 반드시 거침.


0. 요구사항 요약

현재: 온도 프로파일 탭(st-temp)에서 차트 아래 st-temp-meta에 단별 온도/진공을 텍스트로 표시.

목표: st-temp-meta를 **두 개의 표(table)**로 재구성:

왼쪽 표 — 온도 (기존 텍스트 → 표 형식):

┌──────────────┬────────┬──────────────┐
│ 항목         │ 현재값 │ 기준         │
├──────────────┼────────┼──────────────┤
│ eb-A(보텀)  │ 85.6℃ │ 85.1±2.3    │
│ T_B         │ 84.0℃ │ 83.3±2.1    │
│ T_C(민감단) │ 77.2℃ │ 77.0±1.4    │
│ T_D(탑)    │ 74.2℃ │ 74.2±1.6    │
│ ΔT(A-D)    │ 11.4℃ │ —           │
│ 진공        │ 39.7  │ 40.1±3.8    │
└──────────────┴────────┴──────────────┘

오른쪽 표 — 유량 (신규):

┌────────┬────────┬────────┬──────────┬──────────┬──────────┐
│        │ FEED   │ REFLUX │ 제품추출 │ 경비물   │ 중비물   │
│        │FICQ-610│ FICQ-6 │ FICQ-611 │ FICQ-611 │ FICQ-611 │
│ 태그명 │  1.PV │  13.PV │   8.PV   │   4.PV   │   6.PV   │
├────────┼────────┼────────┼──────────┼──────────┼──────────┤
│ PV     │ 896.4  │ 652.4  │ 816.4    │ 14.4     │ 23.4     │
│ SP     │ 900    │ 656    │ 820      │ 18.0     │ 27.0     │
│ OP     │ 53.5   │ 64.5   │ 43.9     │ 33.7     │ 20.8     │
└────────┴────────┴────────┴──────────┴──────────┴──────────┘

실시간/과거 스냅샷 모두 동일 형식.


STEP 1 — 맥락 파악

항목 내용
파일 src/Hc900Crawler/wwwroot/js/steam.js (프론트엔드), SteamAdvisorController.cs (백엔드)
역할 Steam Advisory 대시보드 — 온도 프로파일 이격 모니터
아키텍처 프론트 → API → C# 백엔드가 realtime_table + tempref.json 처리
관련 DB Experion realtime_table (v_tag_summary 기반)

STEP 2 — 구조 탐색

src/Hc900Crawler/
├── wwwroot/
│   ├── js/steam.js          ← stRenderTemp(), stTempScrub()
│   └── panes/steam.html     ← st-temp-meta DOM
├── Controllers/
│   └── SteamAdvisorController.cs  ← TempProfile, TempProfileHistory API
└── appsettings.json         ← SteamAdvisor.Columns

STEP 3 — 코드 읽기 (핵심 부분만)

3.1 steam.jsstRenderTemp(snap) (line 216-286)

  • line 279-285: st-temp-meta DOM에 온도 텍스트 렌더링
    const lines = snapSrc.stages.map(s =>
      `${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃  기준 ...`);
    if (snapSrc.spanAD != null) lines.push(`ΔT(A-D)      ${stFmt(snapSrc.spanAD)}℃`);
    if (snapSrc.vacuum) lines.push(`진공         ${stFmt(snapSrc.vacuum.current)} ...`);
    meta.textContent = lines.join('\n');
    
  • 현재 textContent 사용 → 표 형식 변경 시 innerHTML로 변경 필요

3.2 SteamAdvisorController.csTempProfile (line 183-206)

  • FetchRealtimeValues(col, tref) → 5개 온도/진공 태그 조회
  • ComputeStages(cur, tref) → 제품 매칭 + z-score 계산
  • 반환: stages, vacuum, spanAD

3.3 SteamAdvisorController.csTagsFor(p) (line 352-371)

  • 온도/진공 태그 매핑만 포함. FICQ 태그 없음

3.4 SteamAdvisorController.csTempProfileHistory (line 211-273)

  • history_table 조회 → 각 스냅샷에 stages, vacuum, spanAD 포함

STEP 4 — 호출 계층 지도

[Browser] steam.js stTempLoad() / stTempScrub()
  └─ GET /api/steam/tempprofile/{col}          ← 실시간
  └─ GET /api/steam/tempprofile/{col}/history   ← 과거
      └─ SteamAdvisorController.TempProfile() / TempProfileHistory()
          ├─ FetchRealtimeValues() → HC900 DB (온도 4 + 진공 1)
          └─ ComputeStages() → stages + vacuum + spanAD
      └─ _db.QueryHistoryWithIntervalAsync() → history_table
          └─ ComputeStages() for each snapshot

[Browser] steam.js stRenderTemp(snap)
  └─ stTempUpdateBadges()
  └─ ECharts 렌더
  └─ st-temp-meta.innerHTML = ...   ← 표 형식 (변경 필요)

STEP 5 — 패턴 매칭 (체크리스트 순회)

🔴 런타임 즉시 실패

체크 항목 판단 기준
[ ] textContentinnerHTML 변경 XSS 위험 없음 (모든 값은 stFmt()로 포맷된 숫자)
[ ] API 응답 필드 누락 flow 필드가 백엔드/프론트에서 일관되게 사용되는가?
[ ] 잘못된 타입 flow 값이 null/NaN일 때 stFmt()가 올바르게 처리하는가?

🟠 동시성 / 비동기

체크 항목 판단 기준
[ ] API 응답 구조 변경 기존 stages/vacuum/spanAD 필드 유지 (breaking change 아님)

🟢 코드 구조

체크 항목 판단 기준
[ ] FICQ 태그명 하드코딩 TagsFor()에서 동적 생성 (코드 중복 없음)
[ ] 미사용 import·변수 신규 코드에 미사용 import 없음

STEP 6 — 교차 검증

질문 확인 방법 결과
Q1. 이미 수정된 문제인가? steam.js에 표 형식 로직이 이미 있는가? 아니오 — 현재 텍스트 기반
Q2. 다른 레이어에서 처리되고 있는가? Live 패널에 표 형식이 있는가? 아니오 — Live 패널은 별도 레이아웃
Q3. 의도적 설계인가? 표 형식 재구성이 요구사항에 명시된 기능인가? — 사용자가 명시적으로 요청
Q4. 실제 장애 시나리오가 있는가? 표 형식이 없으면 운전원 판단에 영향이 있는가? — 유량 데이터 없으면 물질수지 판단 불가

STEP 7 — 심각도 분류

등급 항목
🟡 LOW 신규 UI 표시 기능 — 기존 동작에 영향 없음
🟡 LOW 백엔드 API에 신규 필드 추가 — 기존 필드 유지

STEP 8 — 실행 플랜

작업 1 — 백엔드: TagsFor()에 FICQ 태그 추가

파일: SteamAdvisorController.cs:352-371

변경 내용: TagsFor(p)에 FICQ 5개 태그 매핑 추가

private static Dictionary<string, string> TagsFor(string p)
{
    var m = new Dictionary<string, string>
    {
        ["reb_temp"] = $"TICA-{p}A.PV",
        ["T_B"] = $"TI-{p}B.PV",
        ["T_C"] = $"TI-{p}C.PV",
        ["T_D"] = $"TI-{p}D.PV",
        ["vacuum"] = $"PICA-{p}.PV",
        // 추가:
        ["feed"] = $"FICQ-{p}01.PV",
        ["reflux"] = $"FICQ-{p}13.PV",
        ["overhead"] = $"FICQ-{p}14.PV",
        ["bottom"] = $"FICQ-{p}16.PV",
        ["product"] = $"FICQ-{p}18.PV",
    };
    // ... 기존 switch (8111, 9111 등) 유지
    return m;
}

교차 검증: v_tag_summary에서 ficq-6101, ficq-6113, ficq-6114, ficq-6116, ficq-6118이 존재하고 PV 값을 가짐 확인 완료.


작업 2 — 백엔드: FetchRealtimeValues() 변경 없음

TagsFor()가 모든 태그(온도 + 유량)를 반환하므로 FetchRealtimeValues()는 자동 반영. 변경 없음.


작업 3 — 백엔드: TempProfile API 응답에 flow + metadata 필드 추가

파일: SteamAdvisorController.cs:193-205

변경 내용: 반환 객체에 flow + flowTags 필드 추가

return Ok(new
{
    column = tref.Column,
    period = tref.Period,
    matchedProduct = prod?.Label,
    nProducts = tref.NProducts,
    stages,
    vacuum,
    spanAD,
    spanRef = prod?.SpanAD,
    products = tref.Products,
    timestamp = DateTime.UtcNow,
    // 추가:
    flow = new {
        feed = cur.GetValueOrDefault("feed"),
        reflux = cur.GetValueOrDefault("reflux"),
        overhead = cur.GetValueOrDefault("overhead"),
        bottom = cur.GetValueOrDefault("bottom"),
        product = cur.GetValueOrDefault("product"),
    },
    flowTags = new {
        feed = $"FICQ-{ToSuffix(col)}01.PV",
        reflux = $"FICQ-{ToSuffix(col)}13.PV",
        overhead = $"FICQ-{ToSuffix(col)}14.PV",
        bottom = $"FICQ-{ToSuffix(col)}16.PV",
        product = $"FICQ-{ToSuffix(col)}18.PV",
    },
});

동일 변경: TempProfileHistory (line 247-254)에서도 각 스냅샷에 flow + flowTags 추가.


작업 4 — 백엔드: ComputeStages()에서 온도/진공 값 구조화

파일: SteamAdvisorController.cs:308-348

변경 내용: ComputeStages() 반환값에 온도 현재값/기준값을 표 형식으로 렌더링할 수 있도록 구조화

현재 stages{ stage, current, refMedian, refStd, z, deviated } 객체 배열. vacuum{ current, refMedian, refStd, z, deviated } 객체.

이 구조를 그대로 사용하되, 프론트엔드에서 표 형식으로 렌더링할 때:

  • stages[].current → "현재값" 열
  • stages[].refMedian ± stages[].refStd → "기준" 열
  • vacuum.current → "현재값" 열
  • vacuum.refMedian ± vacuum.refStd → "기준" 열

백엔드 변경 없음. 프론트엔드에서 기존 구조로 표 렌더링.


작업 5 — 프론트엔드: stRenderTemp()를 표 형식으로 재작성

파일: steam.js:216-286

변경 내용: textContentinnerHTML, 두 개의 <table> 생성

function stRenderTemp(snap) {
  stTempUpdateBadges();
  if (!stTempLive) {
    const el = document.getElementById('st-chart-temp');
    if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
    return;
  }

  stRenderTempCol = document.getElementById('st-temp-col').value;
  const stages = stTempLive.stages;
  if (!stages || !stages.length) return;

  // ... (ECharts 렌더링 기존 유지 — line 235-276) ...

  // ── 메타 영역을 표 형식으로 렌더링 ──
  const meta = document.getElementById('st-temp-meta');
  meta.style.display = 'block';

  const snapSrc = snap || stTempLive;

  // 왼쪽 표: 온도
  let tempRows = stages.map(s => {
    const label = ST_STAGE_LABEL[s.stage] || s.stage;
    const cur = stFmt(s.current) + '℃';
    const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
    return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
  });
  // ΔT(A-D) 추가
  if (snapSrc.spanAD != null) {
    tempRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
  }
  // 진공 추가
  if (snapSrc.vacuum) {
    const v = snapSrc.vacuum;
    const cur = stFmt(v.current);
    const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
    tempRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
  }

  // 오른쪽 표: 유량
  let flowHeaders, flowRows;
  if (snapSrc.flow) {
    const f = snapSrc.flow;
    const ft = snapSrc.flowTags || {};
    flowHeaders = [
      '<th></th>',
      `<th>FEED<br><small>${esc(ft.feed || 'FICQ-??01.PV')}</small></th>`,
      `<th>REFLUX<br><small>${esc(ft.reflux || 'FICQ-??13.PV')}</small></th>`,
      `<th>제품추출<br><small>${esc(ft.product || 'FICQ-??18.PV')}</small></th>`,
      `<th>경비물<br><small>${esc(ft.overhead || 'FICQ-??14.PV')}</small></th>`,
      `<th>중비물<br><small>${esc(ft.bottom || 'FICQ-??16.PV')}</small></th>`,
    ].join('');
    flowRows = [
      `<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td></tr>`,
      `<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td></tr>`,
      `<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td></tr>`,
    ].join('');
  }

  meta.innerHTML = `
    <div class="st-meta-tables">
      <table class="st-meta-temp">${tempRows.join('')}</table>
      ${flowHeaders ? `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>` : ''}
    </div>
  `;
}

참고: SP/OP 값은 현재 API 응답에 없음. 작업 6에서 API 확장이 필요.


작업 6 — 백엔드: API 응답에 SP/OP 추가 (필수)

파일: SteamAdvisorController.cs

현재 상황: RealtimePoint 엔티티에는 LiveValue만 있음. SP/OP는 v_tag_summary 뷰에서 조회.

변경 내용: FICQ 태그의 PV/SP/OP를 v_tag_summary에서 조회

// v_tag_summary에서 base_tag 기반 조회 (예: ficq-6101, ficq-6113 등)
private async Task<Dictionary<string, (double? pv, double? sp, double? op)>> FetchFlowValues(string col)
{
    var p = ToSuffix(col);
    var baseTags = new[] {
        ("feed", $"ficq-{p}01"),
        ("reflux", $"ficq-{p}13"),
        ("overhead", $"ficq-{p}14"),
        ("bottom", $"ficq-{p}16"),
        ("product", $"ficq-{p}18"),
    };

    var placeholders = string.Join(",", baseTags.Select((_, i) => $"@p{i}"));
    var sql = $"SELECT LOWER(base_tag) as base_tag, pv, sp, op FROM v_tag_summary WHERE LOWER(base_tag) IN ({placeholders})";
    var params = baseTags.Select((bt, i) => new Npgsql.NpgsqlParameter($"p{i}", bt.Item2)).ToArray();

    // Raw SQL 조회 결과 파싱
    var rows = await _ctx.Database.SqlQueryRaw<(string base_tag, string? pv, string? sp, string? op)>(sql, params).ToListAsync();

    var result = new Dictionary<string, (double? pv, double? sp, double? op)>();
    foreach (var kv in baseTags)
    {
        var row = rows.FirstOrDefault(r => r.base_tag == kv.Item2);
        result[kv.Item1] = (
            pv: double.TryParse(row?.pv, out var v) ? (double?)v : null,
            sp: double.TryParse(row?.sp, out var s) ? (double?)s : null,
            op: double.TryParse(row?.op, out var o) ? (double?)o : null
        );
    }
    return result;
}

API 응답 구조:

var flow = await FetchFlowValues(col);
return Ok(new {
    // ... 기존 필드 ...
    flow = new {
        feed = flow["feed"],
        reflux = flow["reflux"],
        overhead = flow["overhead"],
        bottom = flow["bottom"],
        product = flow["product"],
    },
    flowTags = new {
        feed = $"FICQ-{p}01.PV",
        reflux = $"FICQ-{p}13.PV",
        overhead = $"FICQ-{p}14.PV",
        bottom = $"FICQ-{p}16.PV",
        product = $"FICQ-{p}18.PV",
    },
});

작업 7 — CSS 추가

파일: steam.html (style 블록) 또는 별도 CSS

.st-meta-tables {
  display: flex;
  gap: 16px;
  margin-top: 8px;
  font-size: 12px;
  font-family: monospace;
}
.st-meta-temp, .st-meta-flow {
  border-collapse: collapse;
}
.st-meta-temp td, .st-meta-temp th {
  padding: 2px 8px;
  border-bottom: 1px solid #1a2a3a;
  text-align: left;
  white-space: nowrap;
}
.st-meta-flow {
  font-size: 11px;
}
.st-meta-flow th {
  padding: 2px 6px;
  border-bottom: 1px solid #2a3a4a;
  color: #888;
  font-weight: normal;
}
.st-meta-flow th small {
  display: block;
  font-size: 9px;
  color: #555;
  margin-top: 2px;
}
.st-meta-flow td {
  padding: 2px 6px;
  border-bottom: 1px solid #1a2a3a;
  color: #ccc;
}
.st-meta-flow td:first-child {
  color: #666;
  font-weight: bold;
}

작업 8 — 테스트

테스트 방법
실시간 조회 GET /api/steam/tempprofile/C-6111flow + flowTags 필드 확인
과거 조회 GET /api/steam/tempprofile/C-6111/history?from=...&to=... → 각 스냅샷에 flow 포함 확인
UI 렌더링 온도 프로파일 탭 → st-temp-meta에 두 표 표시 확인
Scrubber 슬라이더 이동 → 각 시점의 표 값 변경 확인
실시간 모드 stTempLive.flow 표시 확인
null 처리 PV가 null인 태그 (예: C-9111) → 표시 확인
CSS 렌더링 두 표가 나란히 배치 확인

완료상황 체크

  • 작업 1: TagsFor()에 FICQ 태그 5개 추가 (feed/reflux/overhead/bottom/product)
  • 작업 2: FetchFlowValues() 신규 추가 — v_tag_summary에서 PV/SP/OP 조회
  • 작업 3: TempProfile API 응답에 flow + flowTags 필드 추가
  • 작업 3-H: TempProfileHistory API 응답에 각 스냅샷 flow + flowTags 추가
  • 작업 4: ComputeStages() 변경 없음 확인
  • 작업 5: stRenderTemp() 재작성 — textContentinnerHTML, 두 개의 table 나란히 배치
  • 작업 6: SP/OP 표시 — API + 프론트엔드 모두 구현 완료 (PV/SP/OP 3행)
  • 작업 7: CSS 추가 (.st-meta-tables, .st-meta-temp, .st-meta-flow)
  • 빌드: dotnet build 성공 (0 Error, 8 Warning — 기존 경고만)
  • 테스트 1: 실시간 API — GET /api/steam/tempprofile/C-6111flow + flowTags 확인
  • 테스트 2: 과거 API — GET /api/steam/tempprofile/C-6111/history?from=...&to=... → 스냅샷에 flow 포함 확인
  • 테스트 3: UI 렌더링 — 온도 프로파일 탭 → 두 표 나란히 표시 확인
  • 테스트 4: Scrubber — 슬라이더 이동 → 각 시점 표 값 변경 확인
  • 테스트 5: null 처리 — PV가 null인 태그 (예: C-9111) → 표시 확인
  • 작업 8: 실시간 조회 API 테스트 — flow + flowTags 필드 정상 응답
  • 작업 8: 과거 조회 API 테스트 — 스냅샷에 flow 포함
  • 작업 8: UI 렌더링 테스트 — 두 표 나란히 표시
  • 작업 8: Scrubber 테스트 — 과거 시점 표 값 변경
  • 작업 8: null/미폴링 태그 테스트 — 표시 확인

이해 안 되는 부분 / 확인 사항

  1. SP/OP 표시: 현재 API 응답에 SP/OP가 없음. v_tag_summary에는 존재하므로 API 확장이 필요. 표시할까요? (표에 PV만 표시하면 작업 6 스킵)
  2. FICQ-{NN}14 = Overhead(경비물), FICQ-{NN}16 = Bottom(중비물) — 확인 완료