Files
HC900-Crawler/docs/steam-온도프로파일-제품-사용자정의-수정방안.md
windpacer 18963455f2 feat: Steam 온도프로파일 제품명 사용자정의 + 자동매칭 폐지
- 제품 라벨(P0/P1)에 MSDS 화학물질명(PMA/PGMEA/EL) 매핑 레이어 추가
  (product_labels.json, 플랜트=컬럼별). 통계 기준밴드는 데이터 산출 유지
- 운전원용 ⚙제품명 설정 모달: GET/POST /api/steam/productlabels/{col}
- 온도기반 자동매칭 폐지 → 운전원이 고른 제품만 기준밴드로 비교, 미선택 시작
- 드롭다운: 탭 진입/컬럼변경 시 즉시 채움, 선택은 플랜트별 localStorage 유지
- 중복 '제품(수동)' 배지 제거(헤더 줄바꿈 해소)
- 설계·검수 문서 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:56:50 +09:00

37 KiB
Raw Blame History

Steam Advisory 온도프로파일 — 제품 사용자정의 수정방안

작성일: 2026-06-10 목적: 온도프로파일의 제품(P0/P1/P2) 자동 결정을 ① 화학물질명(MSDS) 기반으로 표기하고 ② 사용자 정의 가능하게 하며 ③ 런타임 자동선택을 사용자 수동선택으로 오버라이드 가능하게 한다. 참조 제품명 출처: docs/MSDS_PMA_PGMEA_EL.md


1. 현황 (As-Is)

1.1 데이터 흐름

gen_temp_profiles.py (KMeans 클러스터)
   └─▶ scripts/analysis/{col}_tempref.json  (products[].label = "P0","P1","P2")
            │
            ▼
SteamAdvisorController.ComputeStages()  ← reb_temp 최근접 클러스터 자동매칭
   └─▶ GET /api/steam/tempprofile/{col}  → { matchedProduct: "P0", products:[...] }
            │
            ▼
steam.js / steam.html  → 배지 "제품 P0" (읽기전용, 자동)

1.2 핵심 코드 위치

단계 파일 위치 동작
제품 라벨 생성 scripts/analysis/gen_temp_profiles.py L55-64 reb_temp 1D KMeans(k=3→2→1) 클러스터링, median 오름차순으로 label=f"P{rank}" 부여. 라인 64 주석에 "추후 PM/PGMEA/EL 등 화학 라벨 매핑" 명시
기준밴드 저장 L61-77 제품별 단별(reb_temp·T_B·T_C·T_D) median/std + vacuum → {col}_tempref.json
런타임 자동매칭 src/Hc900Crawler/Controllers/SteamAdvisorController.cs L400-407 ComputeStages에서 현재 reb_temp와 각 제품 reb_temp.median 차이 최소 제품을 자동선택
API 응답 L184-226 (TempProfile), L231-… (TempProfileHistory) matchedProduct = prod?.Label, products = tref.Products 반환
UI 표시 src/Hc900Crawler/wwwroot/js/steam.js L204-211 (stTempUpdateBadges) 제품 {matchedProduct} 배지. 선택 UI 없음 — 자동값 그대로 표시
UI 마크업 src/Hc900Crawler/wwwroot/panes/steam.html L66 <span id="st-temp-product">제품 —</span> (정적 배지)

1.3 현재 클러스터 실측 (참고)

컬럼 n P0 (저온) P1 (고온) 비고
C-6111 2 reb 84.8 / Tc 84.1 / vac 113 reb 87.5 / Tc 85.5 / vac 113 감압운전이라 절대온도가 MSDS 상압비점(146/154℃)과 다름
C-6211 2 reb 85.4 / vac 113 reb 90.4 / vac 113
C-8111 2 reb 93.4 / vac 50 reb 94.9 / vac 50

⚠ 감압 운전이므로 reb_temp 절대값만으로는 화학물질을 단정할 수 없다. 클러스터→화학물질 매핑은 오퍼레이터 지식 기반의 사용자 정의가 필수.

1.4 문제점

  1. 라벨 P0/P1/P2는 의미 불명 — 오퍼레이터는 화학물질명(PMA/PGMEA/EL)으로 인식.
  2. 제품 개수·경계가 KMeans로 자동 결정되어 운전 실제와 어긋날 수 있고, 사용자가 교정할 수 없음.
  3. 런타임 매칭이 reb_temp 최근접 하나로 자동 고정 — 전환구간·조성변동 시 오판 가능하나 오퍼레이터가 수동으로 기준 제품을 바꿀 수단이 없음.

2. 변경 요구 (To-Be)

# 요구 해석
A 제품명을 MSDS 기준으로 P0/P1/P2PMA / PGMEA / EL 등 화학물질명 표기
B 제품 정의를 사용자 정의로 자동 클러스터 결과를 사용자가 라벨·개수·매핑을 정의/교정 가능
C 자동선택 → 사용자 선택 우선 기본은 온도기반 자동매칭, 사용자가 제품을 고르면 그 제품 기준밴드로 비교

MSDS 제품 후보 (docs/MSDS_PMA_PGMEA_EL.md):

명칭 CAS 상압 비점 비고
PMA 108-65-6 146 ℃ PGMEA와 동일 물질
PGMEA 108-65-6 146 ℃ 반도체 등급 명칭(=PMA)
EL (Ethyl Lactate) 97-64-3 154 ℃ 그린 용제

3. 수정방안

설계 원칙: 통계 기준밴드(median/σ)는 데이터에서 산출(유지) 하고, 표시명·사용자 의도는 별도 매핑 레이어로 분리한다. tempref.json의 클러스터 통계는 그대로 두고 그 위에 사용자 정의를 얹는다.

3.A 제품명 MSDS 매핑 (라벨 레이어 분리)

신규 매핑 파일 도입 — 클러스터(P0/P1/P2)에 화학물질명을 사용자가 부여:

scripts/analysis/product_labels.json (전체 컬럼 한 파일, 사람이 편집)

{
  "C-6111": [
    { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" },
    { "cluster": "P1", "name": "EL",  "casNo": "97-64-3" }
  ],
  "C-6211": [
    { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" },
    { "cluster": "P1", "name": "PGMEA", "casNo": "108-65-6" }
  ]
}

변경:

  1. gen_temp_profiles.py L63-70label(=P{rank})은 클러스터 키로 유지(통계 식별자). 출력 product에 name 필드를 추가하되 기본값은 라벨과 동일(매핑 없을 때 폴백). → 재생성해도 사용자 매핑 보존.
  2. 컨트롤러 LoadTempRef 직후 머지product_labels.json을 읽어 각 product의 name을 cluster 매칭으로 채움. (SteamAdvisorController.cs L187 부근, TempRef/TempProductName 필드 추가 — L500-507)
  3. API 응답matchedProductprod?.Name ?? prod?.Label로 변경 (L201, L281). products[]에도 name 포함.
  4. UI — 배지·툴팁·드롭다운이 name 표시 (steam.js L207, L267-268).

매핑 파일이 없으면 기존처럼 P0/P1/P2로 그대로 동작(하위호환).

3.B 사용자 정의 제품 (클러스터 교정)

3.A의 product_labels.json이 1차 수단(라벨·개수 매핑). 더 나아가 경계/개수까지 사용자 지정이 필요하면:

  • gen_temp_profiles.py에 수동 모드 추가: --manual-bands "C-6111:84-86,86-89" 또는 컬럼별 정의를 product_labels.jsonrebRange:[lo,hi]로 기입 → KMeans 대신 사용자 밴드로 그룹핑.
  • 통계(median/σ)는 사용자 밴드 내 데이터로 재산출.

1단계에서는 3.A(라벨 매핑)만으로 요구 충족 가능. 경계 재정의(3.B 확장)는 운전팀이 KMeans 결과에 불만족할 때 후속으로.

권장: 라벨 편집을 위한 경량 관리 UI는 setup 페인에 “제품명 매핑” 카드로 추가(선택). 우선은 JSON 직접편집으로 시작.

3.C 사용자 수동선택 오버라이드 (런타임)

런타임 자동매칭을 기본값으로 두되 수동 선택 시 우선:

  1. APITempProfile/TempProfileHistory?product=<cluster|name> 쿼리 추가 (L184, L231).
    • 값 있으면 ComputeStages가 자동 최근접 대신 지정 제품의 기준밴드로 z-score·이격 계산.
    • ComputeStages 시그니처에 TempProduct? forced 추가 (L400) — forced ?? 자동매칭.
  2. 응답에 매칭 출처 표기matchSource: "auto" | "manual" 추가 → UI가 “자동/수동” 표시.
  3. UI (steam.html L62-76 / steam.js):
    • st-temp-product 정적 배지를 <select id="st-temp-prod-sel">로 교체. 옵션 = [자동] + products[].name.
    • 기본 [자동]: 현재 동작 유지(매칭 결과를 배지로 표시).
    • 사용자가 제품 선택 → 선택값을 stTempLoad/stTempHistLoad 요청 URL에 &product=로 전달, 해당 제품 밴드로 차트 재렌더.
    • 선택 상태를 stTempSelectedProduct 전역에 보관해 5초 폴링(stTempTick)·스크럽(stTempScrub)에도 유지.

4. 상세 구현 코드

4.A 제품명 MSDS 매핑 (라벨 레이어 분리)

4.A-1 신규 파일: scripts/analysis/product_labels.json

{
  "C-6111": [
    { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" },
    { "cluster": "P1", "name": "EL",  "casNo": "97-64-3" }
  ],
  "C-6211": [
    { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" },
    { "cluster": "P1", "name": "PGMEA", "casNo": "108-65-6" }
  ],
  "C-8111": [
    { "cluster": "P0", "name": "PMA", "casNo": "108-65-6" },
    { "cluster": "P1", "name": "EL",  "casNo": "97-64-3" }
  ]
}

초기값은 운전팀 확인 전 예시. 컬럼별 실제 매핑은 §5-1 확인 후 수정.

4.A-2 gen_temp_profiles.py L63-70 — product에 name 필드 추가

# 기존 L63-70 (변경 전):
products.append({
    "label": f"P{rank}",
    "n_rows": len(g),
    ...
})

# 변경 후:
products.append({
    "label": f"P{rank}",
    "name": f"P{rank}",  # 기본값=label. product_labels.json에서 후처리 채움
    "n_rows": len(g),
    ...
})

4.A-3 SteamAdvisorController.csTempProduct/TempRefName 필드 추가 (L500-507)

// 기존 L500-507 (변경 전):
public sealed record TempProduct
{
    public string Label { get; init; } = "";
    [JsonPropertyName("n_rows")] public int NRows { get; init; }
    [JsonPropertyName("span_AD")] public double SpanAD { get; init; }
    public TempStat Vacuum { get; init; } = new();
    public Dictionary<string, TempStat> Stages { get; init; } = [];
}

// 변경 후:
public sealed record TempProduct
{
    public string Label { get; init; } = "";
    public string Name  { get; init; } = "";  // ← 신규: MSDS 화학물질명 (폴백=Label)
    [JsonPropertyName("n_rows")] public int NRows { get; init; }
    [JsonPropertyName("span_AD")] public double SpanAD { get; init; }
    public TempStat Vacuum { get; init; } = new();
    public Dictionary<string, TempStat> Stages { get; init; } = [];
}

4.A-4 SteamAdvisorController.cs L187 부근 — LoadTempRef 직후 product_labels.json 머지

// LoadTempRef 메서드 직후, TempProfile 액션 내부 (L187 부근):

[HttpGet("tempprofile/{col}")]
public async Task<IActionResult> TempProfile(string col)
{
    var tref = await LoadTempRef(col);
    if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });

    // ── 신규: product_labels.json 머지 ──
    tref = MergeProductLabels(col, tref);
    // ────────────────────────────────────

    var cur = await FetchRealtimeValues(col, tref);
    var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
    ...
}

// TempProfileHistory에도 동일하게 적용 (L239 부근):
[HttpGet("tempprofile/{col}/history")]
public async Task<IActionResult> TempProfileHistory(...)
{
    var tref = await LoadTempRef(col);
    if (tref is null) return NotFound(...);

    // ── 신규: product_labels.json 머지 ──
    tref = MergeProductLabels(col, tref);
    // ────────────────────────────────────
    ...
}

// 헬퍼 메서드 (Controller 내부, TagsFor 메서드 앞에 삽입):

private TempRef MergeProductLabels(string col, TempRef tref)
{
    var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
              ?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
    var path = Path.Combine(dir, "product_labels.json");
    if (!System.IO.File.Exists(path)) return tref;

    try
    {
        var json = System.IO.File.ReadAllText(path);
        using var doc = JsonDocument.Parse(json);
        if (!doc.RootElement.TryGetProperty(col, out var colEntry)) return tref;

        var mapping = colEntry.EnumerateArray()
            .Select(e => (Cluster: e.GetProperty("cluster").GetString(),
                          Name:  e.GetProperty("name").GetString()))
            .ToList();
        if (!mapping.Any()) return tref;

        // 각 product의 Name을 cluster 매칭으로 채움
        foreach (var p in tref.Products)
        {
            var match = mapping.FirstOrDefault(m => m.Cluster == p.Label);
            if (match.Name != null)
                p = p with { Name = match.Name };  // immutable record
            // 매핑 없으면 기존 Label 유지 (폴백)
        }
    }
    catch { /* non-fatal: 매핑 파일 파싱 실패 시 기존 동작 유지 */ }

    return tref;
}

교차 검증: MergeProductLabelsLoadTempRef 직후 호출되므로 tempref.json 구조 변경 없이 레이어 위에서 name을 채움. 파일이 없으면 기존 P0/P1/P2 라벨 그대로 동작(하위호환).

4.A-5 SteamAdvisorController.cs L201, L281 — matchedProductName으로 변경

// L201 (TempProfile 응답):
matchedProduct = prod?.Name ?? prod?.Label,  // ← 변경: Label → Name (폴백=Label)

// L281 (TempProfileHistory 스냅샷):
matchedProduct = prod?.Name ?? prod?.Label,  // ← 변경

// L207 (products 배열에도 name 포함 — TempProduct.Name 필드가 이미 추가되었으므로 자동 직렬화)
products = tref.Products,  // ← 변경 없음. Name 필드가 STJ 직렬화에 포함됨

4.A-6 SteamAdvisorController.cs — API 응답에 matchSource 필드 추가 (3.C와 함께)

// TempProfile 응답 (L197-226, Ok() 내부):
return Ok(new
{
    column = tref.Column,
    period = tref.Period,
    matchedProduct = prod?.Name ?? prod?.Label,
    matchSource = "auto",  // ← 신규: "auto" | "manual"
    nProducts = tref.NProducts,
    stages,
    ...
});

// TempProfileHistory 스냅샷 (L278-301):
snapshots.Add(new
{
    ts = row.TimeBucket,
    matchedProduct = prod?.Name ?? prod?.Label,
    matchSource = "auto",  // ← 신규
    stages,
    ...
});

4.B 사용자 수동선택 오버라이드 (런타임)

4.B-1 SteamAdvisorController.cs L400 — ComputeStages 시그니처 변경 + ?product= 쿼리

// 기존 L400-407 (변경 전):
private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
    Dictionary<string, double?> cur, TempRef tref)
{
    TempProduct? prod = null;
    if (cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0)
        prod = tref.Products
            .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb))
            .First();
    ...
}

// 변경 후:
private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
    Dictionary<string, double?> cur, TempRef tref, TempProduct? forced = null)
{
    TempProduct? prod = forced;  // ← 신규: forced가 있으면 자동매칭 건너뜀
    if (prod is null && cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0)
        prod = tref.Products
            .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb))
            .First();
    ...
}

4.B-2 TempProfile 액션에 ?product= 쿼리 파라미터 추가 (L184-185)

// 기존 L184-185 (변경 전):
[HttpGet("tempprofile/{col}")]
public async Task<IActionResult> TempProfile(string col)

// 변경 후:
[HttpGet("tempprofile/{col}")]
public async Task<IActionResult> TempProfile(
    string col,
    [FromQuery] string? product = null)  // ← 신규: cluster label("P0") 또는 name("PMA")
{
    var tref = await LoadTempRef(col);
    if (tref is null) return NotFound(...);

    tref = MergeProductLabels(col, tref);

    // ── 신규: product 오버라이드 ──
    TempProduct? forced = null;
    if (product != null && tref.Products.Count > 0)
    {
        forced = tref.Products
            .FirstOrDefault(p => p.Label == product || p.Name == product);
    }
    // ──────────────────────────────

    var cur = await FetchRealtimeValues(col, tref);
    var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, forced);  // ← forced 전달

    // matchSource 설정
    string matchSrc = product != null ? "manual" : "auto";

    return Ok(new
    {
        column = tref.Column,
        period = tref.Period,
        matchedProduct = prod?.Name ?? prod?.Label,
        matchSource = matchSrc,  // ← "auto" 또는 "manual"
        nProducts = tref.NProducts,
        stages,
        ...
    });
}

4.B-3 TempProfileHistory 액션에도 ?product= 추가 (L231-235)

// 기존 L231-235 (변경 전):
[HttpGet("tempprofile/{col}/history")]
public async Task<IActionResult> TempProfileHistory(
    string col,
    [FromQuery] DateTime? from = null,
    [FromQuery] DateTime? to = null)

// 변경 후:
[HttpGet("tempprofile/{col}/history")]
public async Task<IActionResult> TempProfileHistory(
    string col,
    [FromQuery] DateTime? from = null,
    [FromQuery] DateTime? to = null,
    [FromQuery] string? product = null)  // ← 신규
{
    ...
    tref = MergeProductLabels(col, tref);

    // ── 신규: product 오버라이드 ──
    TempProduct? forced = null;
    if (product != null && tref.Products.Count > 0)
    {
        forced = tref.Products
            .FirstOrDefault(p => p.Label == product || p.Name == product);
    }
    // ──────────────────────────────

    foreach (var row in result.Rows)
    {
        ...
        var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, forced);  // ← forced 전달
        string matchSrc = product != null ? "manual" : "auto";
        snapshots.Add(new
        {
            ts = row.TimeBucket,
            matchedProduct = prod?.Name ?? prod?.Label,
            matchSource = matchSrc,  // ← 신규
            stages,
            ...
        });
    }
    ...
}

4.C UI 변경 (배지 → 드롭다운, 선택 상태 유지)

4.C-1 steam.html L66 — 정적 배지를 드롭다운으로 교체

<!-- 기존 L66 (변경 전): -->
<span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span>

<!-- 변경 후: -->
<select id="st-temp-prod-sel" class="inp" style="width:120px;font-size:11px">
  <option value="__auto__">[자동]</option>
</select>

4.C-2 steam.js — 전역 상태 변수 추가 (L82 부근)

// 기존 L82 부근에 신규:
const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'],['C-9111','9-1차'],['C-9211','9-2차'],['C-10111','10-1차'],['C-10211','10-2차']];
const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' };
const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1);
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');

// ── 신규: 제품 선택 상태 ──
let stTempSelectedProduct = '__auto__';  // '__auto__' 또는 제품명("PMA","EL","PGMEA")
let stTempMatchSource = 'auto';          // API 응답에서 받은 matchSource

4.C-3 steam.js stTempInit — 드롭다운 옵션 채우기 + 이벤트 바인딩 (L92-111)

function stTempInit() {
  const sel = document.getElementById('st-temp-col');
  if (sel && !sel.options.length)
    sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');

  // ── 신규: 제품 선택 드롭다운 ──
  const prodSel = document.getElementById('st-temp-prod-sel');
  if (prodSel) {
    prodSel.onchange = () => {
      stTempSelectedProduct = prodSel.value;
      stTempLoad();  // 선택 변경 시 재조회
    };
  }
  // ────────────────────────────────

  document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0];
  document.getElementById('st-temp-date').onchange = stTempLoad;
  document.getElementById('st-temp-load').onclick = stTempLoad;
  ...
}

4.C-4 steam.js stTempLoad — 선택 제품을 URL에 전달 (L113-123)

// 기존 L113-123 (변경 전):
async function stTempLoad() {
  if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; }
  stTempSnapshots = [];
  stTempLive = null;
  stTempIsLive = true;
  try {
    stTempLive = await api('GET', `/api/steam/tempprofile/${document.getElementById('st-temp-col').value}`);
  } catch (_) {}
  await stTempHistLoad();
  stTempTimer = setInterval(stTempTick, 5000);
}

// 변경 후:
async function stTempLoad() {
  if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; }
  stTempSnapshots = [];
  stTempLive = null;
  stTempIsLive = true;
  try {
    const col = document.getElementById('st-temp-col').value;
    let url = `/api/steam/tempprofile/${col}`;
    if (stTempSelectedProduct !== '__auto__')
      url += `?product=${encodeURIComponent(stTempSelectedProduct)}`;
    stTempLive = await api('GET', url);
  } catch (_) {}
  await stTempHistLoad();
  stTempTimer = setInterval(stTempTick, 5000);
}

4.C-5 steam.js stTempTick — 제품 선택 유지 + matchSource 반영 (L125-137)

// 기존 L125-137 (변경 전):
async function stTempTick() {
  const col = document.getElementById('st-temp-col').value;
  const st = document.getElementById('st-temp-status');
  try {
    stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
    if (stTempIsLive) {
      stRenderTemp();
    }
    st.textContent = '갱신: ' + new Date().toLocaleTimeString();
  } catch (e) {
    st.textContent = '오류: ' + e.message;
  }
}

// 변경 후:
async function stTempTick() {
  const col = document.getElementById('st-temp-col').value;
  const st = document.getElementById('st-temp-status');
  try {
    let url = `/api/steam/tempprofile/${col}`;
    if (stTempSelectedProduct !== '__auto__')
      url += `?product=${encodeURIComponent(stTempSelectedProduct)}`;
    stTempLive = await api('GET', url);
    if (stTempLive) {
      stTempMatchSource = stTempLive.matchSource || 'auto';
    }
    if (stTempIsLive) {
      stRenderTemp();
    }
    st.textContent = '갱신: ' + new Date().toLocaleTimeString() +
      (stTempMatchSource === 'manual' ? ' (수동)' : '');
  } catch (e) {
    st.textContent = '오류: ' + e.message;
  }
}

4.C-6 steam.js stTempHistLoad — 히스토리에도 제품 전달 (L139-184)

// 기존 L160 (변경 전):
const url = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`;

// 변경 후:
let histUrl = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`;
if (stTempSelectedProduct !== '__auto__')
  histUrl += `&product=${encodeURIComponent(stTempSelectedProduct)}`;
const url = histUrl;

4.C-7 steam.js stTempUpdateBadges — 제품명 + matchSource 표시 (L203-212)

// 기존 L203-212 (변경 전):
function stTempUpdateBadges(src) {
  const s = src || stTempLive;
  if (!s) return;
  document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—');
  const vb = document.getElementById('st-temp-vac');
  vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
  vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
  vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
}

// 변경 후:
function stTempUpdateBadges(src) {
  const s = src || stTempLive;
  if (!s) return;

  // 제품명 배지 → 드롭다운 값 동기화
  const prodSel = document.getElementById('st-temp-prod-sel');
  if (prodSel) {
    const matchedName = s.matchedProduct || '—';
    const matchLabel = stTempMatchSource === 'manual' ? `${matchedName} (수동)` : matchedName;
    // 드롭다운에서 현재 선택값 유지 (자동매칭 결과가 선택과 다르면 드롭다운 값 변경 안함)
    if (stTempSelectedProduct === '__auto__') {
      // 자동 모드: 매칭 결과를 표시
      const badge = prodSel.parentElement.querySelector('.st-badge') ||
                    document.getElementById('st-temp-product');
      if (badge) badge.textContent = '제품 ' + matchedName;
    }
  }

  const vb = document.getElementById('st-temp-vac');
  vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
  vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
  vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
}

4.C-8 steam.js stTempLive 응답 수신 시 드롭다운 옵션 동적 생성

stTempLoadstTempTick에서 API 응답 수신 후 제품 옵션을 드롭다운에 추가:

// stTempLoad / stTempTick 내부, API 응답 수신 직후 추가:

// ── 신규: 드롭다운 옵션 갱신 ──
function stUpdateProdDropdown(resp) {
  const prodSel = document.getElementById('st-temp-prod-sel');
  if (!prodSel || !resp || !resp.products) return;

  const currentVal = prodSel.value;
  const options = resp.products.map(p =>
    `<option value="${escHtml(p.name || p.label)}">${escHtml(p.name || p.label)}</option>`
  ).join('');

  prodSel.innerHTML = `<option value="__auto__">[자동]</option>` + options;

  // 이전 선택값이 옵션에 있으면 유지, 없으면 [자동]으로
  if (prodSel.querySelector(`option[value="${currentVal}"]`)) {
    prodSel.value = currentVal;
  } else {
    prodSel.value = '__auto__';
    stTempSelectedProduct = '__auto__';
  }
}
// ─────────────────────────────────

stTempLoadstTempTick의 API 응답 처리 끝에 호출:

// stTempLoad 내부:
stTempLive = await api('GET', url);
if (stTempLive) stUpdateProdDropdown(stTempLive);  // ← 추가

// stTempTick 내부:
stTempLive = await api('GET', url);
if (stTempLive) {
  stTempMatchSource = stTempLive.matchSource || 'auto';
  stUpdateProdDropdown(stTempLive);  // ← 추가
}

4.C-9 steam.js stRenderTemp — 차트 제목에 matchSource 표시 (L264-268)

// 기존 L266-268 (변경 전):
title: {
  text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
  subtext: snap
    ? `매칭제품 ${snap.matchedProduct || '—'} · 진공 ${stFmt(snap.vacuum?.current)} ...`
    : `매칭제품 ${stTempLive.matchedProduct || '—'} · 진공 ${stFmt(stTempLive.vacuum.current)} ...`,

// 변경 후:
title: {
  text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
  subtext: snap
    ? `매칭제품 ${snap.matchedProduct || '—'} (${snap.matchSource || 'auto'}) · 진공 ${stFmt(snap.vacuum?.current)} ...`
    : `매칭제품 ${stTempLive.matchedProduct || '—'} (${stTempMatchSource}) · 진공 ${stFmt(stTempLive.vacuum.current)} ...`,

4.C-10 steam.js stTempScrub — 스크럽 시 제품 선택 유지 (L191-201)

// 기존 L191-201 (변경 전):
function stTempScrub() {
  const idx = parseInt(document.getElementById('st-scrub-slider').value);
  const snap = stTempSnapshots[idx];
  if (!snap) return;
  stTempIsLive = false;
  document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts);
  stRenderTemp(snap);
}

// 변경 후 (변경 없음 — stTempSnapshots가 이미 선택 제품 기준으로 로드되었으므로 그대로 동작)
// 다만 stRenderTemp에서 snap.matchSource를 사용하도록 4.C-9에서 수정했으므로 자동 반영됨

4.D 변경 파일 요약

# 파일 변경 내용 라인
D1 scripts/analysis/product_labels.json 신규 — 컬럼별 cluster→화학물질명 매핑 신규
D2 scripts/analysis/gen_temp_profiles.py product에 name 필드 추가 (기본값=label) L63-70
D3 SteamAdvisorController.cs TempProduct.Name 필드 추가 L500-507
D4 SteamAdvisorController.cs MergeProductLabels() 헬퍼 추가 TagsFor 앞에
D5 SteamAdvisorController.cs TempProfile?product= 파라미터 + forced 전달 L184-226
D6 SteamAdvisorController.cs TempProfileHistory?product= 파라미터 + forced 전달 L231-314
D7 SteamAdvisorController.cs ComputeStagesforced 파라미터 추가 L400-407
D8 SteamAdvisorController.cs matchSource 필드를 TempProfile/History 응답에 추가 L197, L278
D9 steam.html st-temp-product 배지 → st-temp-prod-sel 드롭다운 L66
D10 steam.js stTempSelectedProduct, stTempMatchSource 전역 변수 L82 부근
D11 steam.js stUpdateProdDropdown() — 드롭다운 옵션 동적 생성 신규
D12 steam.js stTempInit — 드롭다운 이벤트 바인딩 L92-111
D13 steam.js stTempLoad — URL에 ?product= 전달 L113-123
D14 steam.js stTempTick — URL에 ?product= 전달 + matchSource 갱신 L125-137
D15 steam.js stTempHistLoad — URL에 ?product= 전달 L160
D16 steam.js stTempUpdateBadges — matchSource 표시 L203-212
D17 steam.js stRenderTemp — 차트 제목에 (auto/manual) 표시 L266-268

4. 작업 순서 · 영향 파일

단계 작업 파일
1 product_labels.json 스키마 확정 + C-6111 등 초기 매핑 작성 scripts/analysis/product_labels.json (신규)
2 tempref product에 name 필드(폴백=label) scripts/analysis/gen_temp_profiles.py L63-70
3 TempRef/TempProductName 추가 + LoadTempRef 머지 SteamAdvisorController.cs L187, L500-507
4 matchedProduct=name, products[].name 반환 〃 L201, L207, L281
5 ?product= 오버라이드 + ComputeStages(forced) + matchSource 〃 L184, L231, L400-407
6 배지→드롭다운, 선택상태 유지, 요청 URL에 product 전달 steam.html L66, steam.js L113-201, L204-211
7 (선택) setup 페인 제품명 매핑 관리 UI panes/setup.html, SetupController.cs

빌드/테스트: cd src/Hc900Crawler && dotnet build 후 온도프로파일 탭에서 드롭다운·배지·밴드 재렌더 확인.


5. 확인 필요 (Open Questions)

  1. 컬럼별 제품 매핑 실제값 — 각 컬럼(C-6111…C-10211)의 클러스터(P0/P1/…)가 실제로 어떤 화학물질(PMA/PGMEA/EL)인지 운전팀 확인 필요. (감압 reb_temp만으로 자동 단정 불가 — §1.3)
  2. PMA vs PGMEA 표기 — 동일물질(CAS 108-65-6)인데 둘 다 쓸지, 하나로 통일할지.
  3. 사용자 선택 지속성 — 수동선택을 세션 한정으로 둘지, 서버에 저장(컬럼별 마지막 선택)할지.
  4. 경계 재정의(3.B 확장) 필요 여부 — 1단계 라벨매핑만으로 충분한지 운전팀 피드백 후 결정.
  5. advisory(라이브 OP 권장) 영향 범위 — 본 변경은 온도프로파일 탭 한정. 라이브 advisory(Predict)의 product 입력(연속 유량값)과는 별개임을 확인.

6. 검수 및 진단 (Code Review)

검수일: 2026-06-10 · 대상: §4 상세 구현 코드 · 방법: 실제 소스(SteamAdvisorController.cs, steam.js L84-86, gen_temp_profiles.py)와 1:1 대조. 사전 확인 사실 — 컨트롤러에 _config(IConfiguration)·_db(IExperionDbService) 주입 존재(L18-28), LoadTempRef도 동일 SteamAdvisor:ModelDir 사용(L326), escHtml/stFmt/stFmtDT는 steam.js L84-86에 이미 정의됨.

6.1 판정 요약

# 심각도 위치 한줄 진단
F1 🔴 BLOCKER §4.A-4 foreach 반복변수 p 재할당 → 컴파일 에러(CS1656) + 설령 통과해도 List 미반영(레코드 불변)
F2 🔴 BLOCKER §4.A-5 Name 기본값 "" + prod?.Name ?? prod?.Label → 빈문자열은 null이 아니라 폴백 안됨 → 기존 7개 tempref.json에서 제품명 공란
F3 🔴 BLOCKER §4.C-1+4.C-7 제거된 st-temp-product 참조 + 폴백 querySelector('.st-badge')진공배지(st-temp-vac)를 덮어씀
F4 🟠 HIGH §4.B-2/4.B-3 미매칭(오타)인데 matchSource="manual" 오표기 (forced=null인데 manual)
F5 🟡 MED §4.C-8 5초 틱마다 select.innerHTML 재생성 → 드롭다운 깜빡임/사용자 조작 방해
F6 🟡 MED §4.A-6 ↔ §4.B-2 matchSource 하드코딩(4.A-6) vs 변수(4.B-2) 중복 — 4.A-6만 적용 시 항상 "auto"
F7 🟢 LOW §4.C-2 escHtml/stFmt는 이미 존재 → 코드블록 그대로 붙이면 const 중복선언 에러
F8 🟢 LOW 문서 ## 5 헤딩 2개 중복(L726, L742) — 앞 표는 "작업 순서"인데 "확인 필요"로 오기

6.2 블로커 상세 + 수정 코드

F1 — MergeProductLabels foreach 재할당 (§4.A-4)

// ❌ 문서 코드: 컴파일 안 됨 (foreach 변수는 읽기전용) + 레코드라 List 미반영
foreach (var p in tref.Products) {
    var match = mapping.FirstOrDefault(m => m.Cluster == p.Label);
    if (match.Name != null) p = p with { Name = match.Name };  // CS1656
}
return tref;

// ✅ 수정: 새 리스트 재구성 후 record-with로 교체
var newProducts = tref.Products.Select(p => {
    var match = mapping.FirstOrDefault(m => m.Cluster == p.Label);
    return string.IsNullOrEmpty(match.Name) ? p : p with { Name = match.Name };
}).ToList();
return tref with { Products = newProducts };

F2 — Name 폴백이 빈문자열에서 깨짐 (§4.A-5)

TempProduct.Name의 기본값이 ""(§4.A-3)인데, 기존 7개 *_tempref.json에는 name 필드가 없어 역직렬화 후 Name="". prod?.Name ?? prod?.Label??null에만 폴백하므로 ""(빈문자열)이 그대로 반환 → UI 제품명 공란. (gen_temp_profiles.py로 재생성하기 전까지 전 컬럼 영향)

// ❌ 문서 코드 (L276, L279, L293, L304, L380, L427)
matchedProduct = prod?.Name ?? prod?.Label,

// ✅ 수정: 빈문자열까지 폴백 (헬퍼 1개로 통일 권장)
matchedProduct = string.IsNullOrEmpty(prod?.Name) ? prod?.Label : prod!.Name,

또는 §4.A-3에서 Name 기본값을 null로(public string? Name { get; init; }) 두면 ?? 그대로 동작. 둘 중 하나 택일.

F3 — UI 배지/드롭다운 충돌 (§4.C-1 + §4.C-7)

st-temp-product span을 select로 교체(4.C-1)했는데 4.C-7이 그 id를 다시 찾고, 폴백 prodSel.parentElement.querySelector('.st-badge')는 같은 바의 **진공 배지(st-temp-vac, class st-badge st-badge-conf)**를 선택해 "제품 …"으로 덮어씀 → 진공 표시 파괴. 또한 자동모드에서 매칭 제품명을 보여줄 자리가 사라짐(설계 공백).

<!-- ✅ 권장: select와 별도 배지를 함께 유지 (배지는 자동매칭 결과 표시 전용) -->
<select id="st-temp-prod-sel" class="inp" style="width:120px;font-size:11px">
  <option value="__auto__">[자동]</option>
</select>
<span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span>
// ✅ 4.C-7: 전용 배지만 갱신 (querySelector 폴백 삭제)
const badge = document.getElementById('st-temp-product');
if (badge) badge.textContent = '제품 ' + matchedName + (stTempMatchSource === 'manual' ? ' (수동)' : '');

6.3 그 외 수정

  • F4: string matchSrc = forced != null ? "manual" : "auto"; (product 지정했어도 실제 매칭됐을 때만 manual). §4.B-2·§4.B-3 양쪽.
  • F5: 드롭다운 옵션 구성은 컬럼 변경/최초 로드 시 1회만. stTempTick(5초)에서는 stUpdateProdDropdown 호출 제거(또는 옵션 동일 시 no-op). 매 틱 innerHTML 재작성 금지.
  • F6: 문서에 "§4.A-6은 §4.B-2/4.B-3로 대체됨(최종본은 변수 matchSrc)" 명시. 구현 시 4.A-6 하드코딩 블록 생략.
  • F7: §4.C-2에서 신규 2줄(let stTempSelectedProduct, let stTempMatchSource)만 추가. const stFmt/escHtml은 이미 steam.js L84-85에 있으니 재선언 금지(컨텍스트 표기일 뿐).
  • F8: ## 5 중복 헤딩 정리 — 앞(L726)은 ## 4. 작업 순서·영향 파일로, 뒤(L742)만 ## 5. 확인 필요로.

6.4 정상 확인 (문제 없음)

  • _config.GetValue<string>("SteamAdvisor:ModelDir") 사용 — 컨트롤러 필드 존재·LoadTempRef와 동일 경로 로직 ✓
  • ComputeStagesTempProduct? forced = null 추가(static 메서드) — 시그니처·호출부 정합 ✓
  • forced 매칭키 p.Label == product || p.Name == product ↔ 드롭다운 옵션값 p.name || p.label — 키 정합 ✓
  • products = tref.Products 직렬화 시 Namename(ASP.NET camelCase 기본) — 프론트 p.name 수신 가능 ✓
  • MergeProductLabels try/catch 비치명 처리·파일 부재 시 원본 반환 — 하위호환 ✓

6.5 진단: 적용 순서 권장

  1. F1·F2 먼저 (백엔드 컴파일/표시 정확성) → dotnet build 통과 확인.
  2. 기존 7개 tempref.json은 name 없음 → F2 폴백 수정이 없으면 매핑 미작성 컬럼이 전부 공란. 우선 F2 적용, 이후 product_labels.json 작성.
  3. F3·F5·F7 UI 수정 → 온도프로파일 탭에서 ①자동 배지 표시 ②드롭다운 수동선택 시 밴드 재계산 ③5초 폴링 중 드롭다운 안정성 육안 확인.
  4. 회귀: product_labels.json 부재 상태에서 기존과 동일하게 P0/P1 표시되는지(하위호환) 먼저 검증 후 매핑 파일 투입.

이 문서는 수정방안(설계) + §4 구현 코드 + §6 검수를 포함한다. F1~F3(블로커) 해소 전에는 빌드/동작 불가.