- 제품 라벨(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>
37 KiB
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 문제점
- 라벨
P0/P1/P2는 의미 불명 — 오퍼레이터는 화학물질명(PMA/PGMEA/EL)으로 인식. - 제품 개수·경계가 KMeans로 자동 결정되어 운전 실제와 어긋날 수 있고, 사용자가 교정할 수 없음.
- 런타임 매칭이
reb_temp최근접 하나로 자동 고정 — 전환구간·조성변동 시 오판 가능하나 오퍼레이터가 수동으로 기준 제품을 바꿀 수단이 없음.
2. 변경 요구 (To-Be)
| # | 요구 | 해석 |
|---|---|---|
| A | 제품명을 MSDS 기준으로 | P0/P1/P2 → PMA / 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" }
]
}
변경:
gen_temp_profiles.pyL63-70 —label(=P{rank})은 클러스터 키로 유지(통계 식별자). 출력 product에name필드를 추가하되 기본값은 라벨과 동일(매핑 없을 때 폴백). → 재생성해도 사용자 매핑 보존.- 컨트롤러
LoadTempRef직후 머지 —product_labels.json을 읽어 각 product의name을 cluster 매칭으로 채움. (SteamAdvisorController.csL187 부근,TempRef/TempProduct에Name필드 추가 — L500-507) - API 응답 —
matchedProduct를prod?.Name ?? prod?.Label로 변경 (L201, L281).products[]에도name포함. - 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.json에rebRange:[lo,hi]로 기입 → KMeans 대신 사용자 밴드로 그룹핑.- 통계(median/σ)는 사용자 밴드 내 데이터로 재산출.
1단계에서는 3.A(라벨 매핑)만으로 요구 충족 가능. 경계 재정의(3.B 확장)는 운전팀이 KMeans 결과에 불만족할 때 후속으로.
권장: 라벨 편집을 위한 경량 관리 UI는 setup 페인에 “제품명 매핑” 카드로 추가(선택). 우선은 JSON 직접편집으로 시작.
3.C 사용자 수동선택 오버라이드 (런타임)
런타임 자동매칭을 기본값으로 두되 수동 선택 시 우선:
- API —
TempProfile/TempProfileHistory에?product=<cluster|name>쿼리 추가 (L184, L231).- 값 있으면
ComputeStages가 자동 최근접 대신 지정 제품의 기준밴드로 z-score·이격 계산. ComputeStages시그니처에TempProduct? forced추가 (L400) —forced ?? 자동매칭.
- 값 있으면
- 응답에 매칭 출처 표기 —
matchSource: "auto" | "manual"추가 → UI가 “자동/수동” 표시. - 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.cs — TempProduct/TempRef에 Name 필드 추가 (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;
}
교차 검증:
MergeProductLabels는LoadTempRef직후 호출되므로 tempref.json 구조 변경 없이 레이어 위에서 name을 채움. 파일이 없으면 기존P0/P1/P2라벨 그대로 동작(하위호환).
4.A-5 SteamAdvisorController.cs L201, L281 — matchedProduct를 Name으로 변경
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
// ── 신규: 제품 선택 상태 ──
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 응답 수신 시 드롭다운 옵션 동적 생성
stTempLoad와 stTempTick에서 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__';
}
}
// ─────────────────────────────────
stTempLoad와 stTempTick의 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 |
ComputeStages에 forced 파라미터 추가 |
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/TempProduct에 Name 추가 + 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)
- 컬럼별 제품 매핑 실제값 — 각 컬럼(C-6111…C-10211)의 클러스터(P0/P1/…)가 실제로 어떤 화학물질(PMA/PGMEA/EL)인지 운전팀 확인 필요. (감압 reb_temp만으로 자동 단정 불가 — §1.3)
- PMA vs PGMEA 표기 — 동일물질(CAS 108-65-6)인데 둘 다 쓸지, 하나로 통일할지.
- 사용자 선택 지속성 — 수동선택을 세션 한정으로 둘지, 서버에 저장(컬럼별 마지막 선택)할지.
- 경계 재정의(3.B 확장) 필요 여부 — 1단계 라벨매핑만으로 충분한지 운전팀 피드백 후 결정.
- advisory(라이브 OP 권장) 영향 범위 — 본 변경은 온도프로파일 탭 한정. 라이브 advisory(
Predict)의 product 입력(연속 유량값)과는 별개임을 확인.
6. 검수 및 진단 (Code Review)
검수일: 2026-06-10 · 대상: §4 상세 구현 코드 · 방법: 실제 소스(
SteamAdvisorController.cs,steam.jsL84-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와 동일 경로 로직 ✓ComputeStages에TempProduct? forced = null추가(static 메서드) — 시그니처·호출부 정합 ✓forced매칭키p.Label == product || p.Name == product↔ 드롭다운 옵션값p.name || p.label— 키 정합 ✓products = tref.Products직렬화 시Name→name(ASP.NET camelCase 기본) — 프론트p.name수신 가능 ✓MergeProductLabelstry/catch 비치명 처리·파일 부재 시 원본 반환 — 하위호환 ✓
6.5 진단: 적용 순서 권장
- F1·F2 먼저 (백엔드 컴파일/표시 정확성) →
dotnet build통과 확인. - 기존 7개 tempref.json은
name없음 → F2 폴백 수정이 없으면 매핑 미작성 컬럼이 전부 공란. 우선 F2 적용, 이후product_labels.json작성. - F3·F5·F7 UI 수정 → 온도프로파일 탭에서 ①자동 배지 표시 ②드롭다운 수동선택 시 밴드 재계산 ③5초 폴링 중 드롭다운 안정성 육안 확인.
- 회귀:
product_labels.json부재 상태에서 기존과 동일하게P0/P1표시되는지(하위호환) 먼저 검증 후 매핑 파일 투입.
이 문서는 수정방안(설계) + §4 구현 코드 + §6 검수를 포함한다. F1~F3(블로커) 해소 전에는 빌드/동작 불가.