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>
This commit is contained in:
windpacer
2026-06-10 16:56:50 +09:00
parent a193d9b364
commit 18963455f2
6 changed files with 1211 additions and 27 deletions

View File

@@ -0,0 +1,845 @@
# Steam Advisory 온도프로파일 — 제품 사용자정의 수정방안
> 작성일: 2026-06-10
> 목적: 온도프로파일의 **제품(P0/P1/P2) 자동 결정**을 ① 화학물질명(MSDS) 기반으로 표기하고
> ② 사용자 정의 가능하게 하며 ③ 런타임 자동선택을 **사용자 수동선택으로 오버라이드** 가능하게 한다.
> 참조 제품명 출처: [`docs/MSDS_PMA_PGMEA_EL.md`](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/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` (전체 컬럼 한 파일, 사람이 편집)
```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-70** — `label`(=`P{rank}`)은 **클러스터 키로 유지**(통계 식별자). 출력 product에 `name` 필드를 추가하되 기본값은 라벨과 동일(매핑 없을 때 폴백). → 재생성해도 사용자 매핑 보존.
2. **컨트롤러 `LoadTempRef` 직후 머지**`product_labels.json`을 읽어 각 product의 `name`을 cluster 매칭으로 채움. (`SteamAdvisorController.cs` L187 부근, `TempRef`/`TempProduct``Name` 필드 추가 — L500-507)
3. **API 응답**`matchedProduct``prod?.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.json``rebRange:[lo,hi]`로 기입 → KMeans 대신 사용자 밴드로 그룹핑.
- 통계(median/σ)는 사용자 밴드 내 데이터로 재산출.
> 1단계에서는 **3.A(라벨 매핑)만으로 요구 충족** 가능. 경계 재정의(3.B 확장)는 운전팀이 KMeans 결과에 불만족할 때 후속으로.
권장: 라벨 편집을 위한 경량 관리 UI는 `setup` 페인에 “제품명 매핑” 카드로 추가(선택). 우선은 JSON 직접편집으로 시작.
### 3.C 사용자 수동선택 오버라이드 (런타임)
런타임 자동매칭을 기본값으로 두되 **수동 선택 시 우선**:
1. **API**`TempProfile`/`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`
```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` 필드 추가
```python
# 기존 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)
```csharp
// 기존 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` 머지
```csharp
// 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`으로 변경
```csharp
// 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와 함께)
```csharp
// 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=` 쿼리
```csharp
// 기존 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)
```csharp
// 기존 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)
```csharp
// 기존 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 — 정적 배지를 드롭다운으로 교체
```html
<!-- 기존 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 부근)
```javascript
// 기존 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)
```javascript
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)
```javascript
// 기존 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)
```javascript
// 기존 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)
```javascript
// 기존 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)
```javascript
// 기존 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 응답 수신 후 제품 옵션을 드롭다운에 추가:
```javascript
// 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 응답 처리 끝에 호출:
```javascript
// 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)
```javascript
// 기존 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)
```javascript
// 기존 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)
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)**
```csharp
// ❌ 문서 코드: 컴파일 안 됨 (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로 재생성하기 전까지 전 컬럼 영향)
```csharp
// ❌ 문서 코드 (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`)**를 선택해 "제품 …"으로 덮어씀 → 진공 표시 파괴. 또한 자동모드에서 매칭 제품명을 보여줄 자리가 사라짐(설계 공백).
```html
<!-- ✅ 권장: 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>
```
```javascript
// ✅ 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` 수신 가능 ✓
- `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(블로커) 해소 전에는 빌드/동작 불가.*

View File

@@ -61,7 +61,8 @@ def build(prefix, stable_from=None, stable_to=None):
stages = {s: {"median": round(float(g[s].median()), 2),
"std": round(float(g[s].std()), 2)} for s in STAGES}
products.append({
"label": f"P{rank}", # 추후 PM/PGMEA/EL 등 화학 라벨 매핑
"label": f"P{rank}",
"name": f"P{rank}", # 기본값=label. product_labels.json에서 후처리 채움
"n_rows": len(g),
"span_AD": round(float((g["reb_temp"] - g["T_D"]).median()), 2),
"vacuum": {"median": round(float(g["vacuum"].median()), 2),

View File

@@ -0,0 +1,12 @@
{
"C-6111": [
{
"cluster": "P0",
"name": "PGMEA"
},
{
"cluster": "P1",
"name": "PMA"
}
]
}

View File

@@ -182,14 +182,22 @@ public sealed class SteamAdvisorController : ControllerBase
// realtime 단별 온도/진공 vs 기준밴드({col}_tempref.json) → 제품매칭 + z-score 이격.
// col 규약 = 컬럼키("C-6111","C-6211","C-8111",...).
[HttpGet("tempprofile/{col}")]
public async Task<IActionResult> TempProfile(string col)
public async Task<IActionResult> TempProfile(
string col,
[FromQuery] string? product = null)
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
// 운전원이 고른 제품만 기준밴드로 사용 (자동매칭 폐지). 미선택이면 selected=null → 밴드 없음.
TempProduct? selected = (product != null && tref.Products.Count > 0)
? tref.Products.FirstOrDefault(p => p.Label == product || p.Name == product)
: null;
var cur = await FetchRealtimeValues(col, tref);
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
if (prod is null) tref = tref with { }; // no-op, keep tref for metadata
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected);
var flow = await FetchFlowValues(col);
var bases = FlowBases(ToSuffix(col));
@@ -198,13 +206,20 @@ public sealed class SteamAdvisorController : ControllerBase
{
column = tref.Column,
period = tref.Period,
matchedProduct = prod?.Label,
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
nProducts = tref.NProducts,
stages,
vacuum,
spanAD,
spanRef = prod?.SpanAD,
products = tref.Products,
// 소문자 키로 projection (TempProduct 레코드는 PascalCase 직렬화 → 프론트가 못 읽음)
products = tref.Products.Select(p => new {
label = p.Label,
name = p.Name,
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
vacMedian = p.Vacuum.Median,
nRows = p.NRows,
}).ToList(),
timestamp = DateTime.UtcNow,
flow = new {
feed = flow["feed"],
@@ -232,13 +247,21 @@ public sealed class SteamAdvisorController : ControllerBase
public async Task<IActionResult> TempProfileHistory(
string col,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null)
[FromQuery] DateTime? to = null,
[FromQuery] string? product = null)
{
try
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
// 운전원이 고른 제품만 기준밴드로 사용 (자동매칭 폐지). 미선택이면 null.
TempProduct? selected = (product != null && tref.Products.Count > 0)
? tref.Products.FirstOrDefault(p => p.Label == product || p.Name == product)
: null;
to ??= DateTime.UtcNow;
from ??= to.Value.AddHours(-1);
// 24시간 초과 방지 (전일 조회 대응)
@@ -257,7 +280,6 @@ public sealed class SteamAdvisorController : ControllerBase
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
var snapshots = new List<object>();
var products = tref.Products;
static double? V(IReadOnlyDictionary<string, string?> d, string tag)
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
@@ -272,13 +294,13 @@ public sealed class SteamAdvisorController : ControllerBase
return (double?)null;
});
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected);
object FlowRole(string baseTag, string role)
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
snapshots.Add(new
{
ts = row.TimeBucket,
matchedProduct = prod?.Label,
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
stages,
vacuum,
spanAD,
@@ -319,8 +341,127 @@ public sealed class SteamAdvisorController : ControllerBase
}
}
// ── 제품명 매핑 편집 (운전원 설정 모달용) ─────────────────────────
// MSDS 후보 (docs/MSDS_PMA_PGMEA_EL.md). 자유 입력도 허용하되 기본 제안 목록 제공.
private static readonly object[] _productSuggestions =
{
new { name = "PMA", casNo = "108-65-6" },
new { name = "PGMEA", casNo = "108-65-6" },
new { name = "EL", casNo = "97-64-3" },
};
private string ProductLabelsPath()
{
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
return Path.Combine(dir, "product_labels.json");
}
// 모달이 클러스터별(온도통계 + 현재 지정명)을 표시하도록 제공.
[HttpGet("productlabels/{col}")]
public async Task<IActionResult> GetProductLabels(string col)
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
var clusters = tref.Products.Select(p => new
{
cluster = p.Label,
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
vacMedian = p.Vacuum.Median,
nRows = p.NRows,
// 현재 지정명: 매핑이 있으면 Name, 없으면 빈값(=미지정)
name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name,
}).ToList();
return Ok(new { column = col, clusters, suggestions = _productSuggestions });
}
public sealed record ProductLabelEntry
{
public string Cluster { get; init; } = "";
public string Name { get; init; } = "";
public string? CasNo { get; init; }
}
// 모달 저장 → product_labels.json의 해당 컬럼 항목만 교체(다른 컬럼 보존). 파일 없으면 생성.
[HttpPost("productlabels/{col}")]
public IActionResult SaveProductLabels(string col, [FromBody] List<ProductLabelEntry> entries)
{
if (entries is null) return BadRequest(new { error = "본문(entries) 누락" });
var path = ProductLabelsPath();
try
{
// 기존 파일 읽어 dictionary로 (없으면 빈 맵)
var root = new Dictionary<string, List<ProductLabelEntry>>(StringComparer.Ordinal);
if (System.IO.File.Exists(path))
{
var existing = JsonSerializer.Deserialize<Dictionary<string, List<ProductLabelEntry>>>(
System.IO.File.ReadAllText(path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (existing != null) root = new(existing, StringComparer.Ordinal);
}
// 이름이 비지 않은 항목만 저장(빈값=미지정 → P0/P1 폴백)
var clean = entries
.Where(e => !string.IsNullOrWhiteSpace(e.Cluster) && !string.IsNullOrWhiteSpace(e.Name))
.Select(e => new ProductLabelEntry { Cluster = e.Cluster.Trim(), Name = e.Name.Trim(), CasNo = e.CasNo })
.ToList();
if (clean.Count == 0) root.Remove(col); // 전부 미지정이면 컬럼 키 제거
else root[col] = clean;
var json = JsonSerializer.Serialize(root, new JsonSerializerOptions
{
WriteIndented = true,
// MergeProductLabels가 "cluster"/"name"을 대소문자 구분해 읽으므로 camelCase 강제
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
System.IO.File.WriteAllText(path, json);
return Ok(new { success = true, saved = clean.Count, path });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, error = ex.Message });
}
}
// ── 헬퍼 ──────────────────────────────────────────────────────────
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;
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 };
}
catch { /* non-fatal: 매핑 파일 파싱 실패 시 기존 동작 유지 */ }
return tref;
}
private async Task<TempRef?> LoadTempRef(string col)
{
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
@@ -398,13 +539,10 @@ public sealed class SteamAdvisorController : ControllerBase
}
private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
Dictionary<string, double?> cur, TempRef tref)
Dictionary<string, double?> cur, TempRef tref, TempProduct? selected = null)
{
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();
// 자동매칭 폐지 — 운전원이 고른 제품만 기준밴드로 사용. 미선택(null)이면 밴드 없음.
TempProduct? prod = selected;
static double? Z(double? c, TempStat? r)
=> (c is double cv && r is not null && r.Std > 1e-6) ? (cv - r.Median) / r.Std : null;
@@ -500,6 +638,7 @@ public sealed record TempRef
public sealed record TempProduct
{
public string Label { get; init; } = "";
public string? Name { get; init; } // MSDS 화학물질명 (null이면 Label 폴백)
[JsonPropertyName("n_rows")] public int NRows { get; init; }
[JsonPropertyName("span_AD")] public double SpanAD { get; init; }
public TempStat Vacuum { get; init; } = new();

View File

@@ -83,6 +83,33 @@ const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'],
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 = ''; // 운전원이 고른 제품명("PMA","EL"…). 빈값=미선택
// 선택을 플랜트(컬럼)별로 localStorage에 저장 → 새로고침·틱에도 유지
function stProdKey(col) { return 'st-prod-sel:' + col; }
function stGetSavedProduct(col) { try { return localStorage.getItem(stProdKey(col)) || ''; } catch (_) { return ''; } }
function stSetSavedProduct(col, v) {
try { v ? localStorage.setItem(stProdKey(col), v) : localStorage.removeItem(stProdKey(col)); } catch (_) {}
}
// 제품 드롭다운을 저장된 매핑(productlabels)으로 채움. 조회와 무관하게 탭 진입/컬럼변경 시 즉시 호출.
async function stLoadProdOptions(col) {
const sel = document.getElementById('st-temp-prod-sel');
if (!sel) return;
let data;
try { data = await api('GET', `/api/steam/productlabels/${col}`); } catch (_) { return; }
const opts = (data.clusters || []).map(c => {
const v = c.name || c.cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…)
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
}).join('');
const saved = stGetSavedProduct(col);
sel.innerHTML = `<option value="">제품 선택…</option>` + opts;
sel.value = [...sel.options].some(o => o.value === saved) ? saved : '';
stTempSelectedProduct = sel.value;
}
let stTempLastDropdownOptions = ''; // 드롭다운 옵션 캐시 (F5: 깜빡임 방지)
const stFmtDT = ts => {
const d = new Date(ts);
const kst = new Date(d.getTime() + 9 * 3600000);
@@ -93,7 +120,22 @@ 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('');
// default date = today
// 컬럼 변경 시 해당 플랜트의 제품 옵션·저장선택 복원 후 재조회
if (sel) sel.onchange = async () => { await stLoadProdOptions(sel.value); stTempLoad(); };
// 제품 선택 드롭다운 — 선택을 플랜트별로 저장(유지)
const prodSel = document.getElementById('st-temp-prod-sel');
if (prodSel) {
prodSel.onchange = () => {
stTempSelectedProduct = prodSel.value;
stSetSavedProduct(document.getElementById('st-temp-col').value, prodSel.value);
stTempLoad(); // 선택 변경 시 재조회
};
}
// 탭 진입 즉시 드롭다운 채움(조회/저장 없이도 저장된 제품명 표시 + 선택 복원)
if (sel) stLoadProdOptions(sel.value);
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;
@@ -108,6 +150,75 @@ function stTempInit() {
slider.addEventListener('input', stTempScrub);
slider.addEventListener('change', stTempScrub);
document.getElementById('st-scrub-live').onclick = stTempGoLive;
// 제품명 설정 모달
const cfgBtn = document.getElementById('st-temp-prod-cfg');
if (cfgBtn) cfgBtn.onclick = stOpenProdModal;
const x = document.getElementById('st-prod-modal-x');
const cancel = document.getElementById('st-prod-modal-cancel');
if (x) x.onclick = stCloseProdModal;
if (cancel) cancel.onclick = stCloseProdModal;
const save = document.getElementById('st-prod-modal-save');
if (save) save.onclick = stSaveProdLabels;
const modal = document.getElementById('st-prod-modal');
if (modal) modal.onclick = (e) => { if (e.target === modal) stCloseProdModal(); };
}
// ── 제품명 설정 모달 ──────────────────────────────────────────────
async function stOpenProdModal() {
const col = document.getElementById('st-temp-col').value;
const modal = document.getElementById('st-prod-modal');
const rows = document.getElementById('st-prod-rows');
const msg = document.getElementById('st-prod-modal-msg');
document.getElementById('st-prod-modal-col').textContent = col;
msg.textContent = ''; msg.className = 'st-modal-msg';
rows.innerHTML = '<tr><td colspan="5" style="color:#789">불러오는 중…</td></tr>';
modal.classList.remove('hidden');
let data;
try {
data = await api('GET', `/api/steam/productlabels/${col}`);
} catch (e) {
rows.innerHTML = `<tr><td colspan="5" style="color:#f66">로드 실패: ${escHtml(e.message)}</td></tr>`;
return;
}
// 제안 목록(datalist)
const dl = document.getElementById('st-prod-suggest');
dl.innerHTML = (data.suggestions || []).map(s => `<option value="${escHtml(s.name)}">`).join('');
// 클러스터별 행 (cluster를 data 속성으로 보관)
rows.innerHTML = (data.clusters || []).map(c => `
<tr>
<td><strong>${escHtml(c.cluster)}</strong></td>
<td class="num">${stFmt(c.rebMedian)}</td>
<td class="num">${stFmt(c.vacMedian)}</td>
<td class="num">${c.nRows ?? '—'}</td>
<td><input class="inp st-prod-name" list="st-prod-suggest"
data-cluster="${escHtml(c.cluster)}"
value="${escHtml(c.name || '')}" placeholder="${escHtml(c.cluster)} (미지정)"></td>
</tr>`).join('') || '<tr><td colspan="5" style="color:#789">클러스터 없음</td></tr>';
}
function stCloseProdModal() {
document.getElementById('st-prod-modal').classList.add('hidden');
}
async function stSaveProdLabels() {
const col = document.getElementById('st-temp-col').value;
const msg = document.getElementById('st-prod-modal-msg');
const entries = [...document.querySelectorAll('#st-prod-rows .st-prod-name')].map(inp => ({
cluster: inp.dataset.cluster,
name: inp.value.trim(),
}));
msg.textContent = '저장 중…'; msg.className = 'st-modal-msg';
try {
const r = await api('POST', `/api/steam/productlabels/${col}`, entries);
msg.textContent = `저장됨 (${r.saved}개 지정)`; msg.className = 'st-modal-msg ok';
setTimeout(() => { stCloseProdModal(); stTempLoad(); }, 600); // 저장 후 재조회로 즉시 반영
} catch (e) {
msg.textContent = '저장 실패: ' + e.message; msg.className = 'st-modal-msg err';
}
}
async function stTempLoad() {
@@ -116,20 +227,42 @@ async function stTempLoad() {
stTempLive = null;
stTempIsLive = true;
try {
stTempLive = await api('GET', `/api/steam/tempprofile/${document.getElementById('st-temp-col').value}`);
const col = document.getElementById('st-temp-col').value;
let url = `/api/steam/tempprofile/${col}`;
if (stTempSelectedProduct)
url += `?product=${encodeURIComponent(stTempSelectedProduct)}`;
stTempLive = await api('GET', url);
if (stTempLive) stUpdateProdDropdown(stTempLive);
} catch (_) {}
await stTempHistLoad();
stTempTimer = setInterval(stTempTick, 5000);
}
// 제품 선택 드롭다운 옵션을 API 응답(products)으로 채움. 사용자 선택은 유지.
function stUpdateProdDropdown(resp) {
const sel = document.getElementById('st-temp-prod-sel');
if (!sel || !resp || !Array.isArray(resp.products)) return;
const prev = stTempSelectedProduct;
const opts = resp.products.map(p => {
const v = p.name || p.label;
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
}).join('');
sel.innerHTML = `<option value="">제품 선택…</option>` + opts;
// 이전 선택값이 여전히 옵션에 있으면 유지, 없으면 미선택
sel.value = [...sel.options].some(o => o.value === prev) ? prev : '';
stTempSelectedProduct = sel.value;
}
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();
}
let url = `/api/steam/tempprofile/${col}`;
if (stTempSelectedProduct)
url += `?product=${encodeURIComponent(stTempSelectedProduct)}`;
stTempLive = await api('GET', url);
// F5: 드롭다운 옵션 갱신은 최초 1회만 (매 틱 innerHTML 재작성 금지)
if (stTempLive && stTempIsLive) stRenderTemp();
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
} catch (e) {
st.textContent = '오류: ' + e.message;
@@ -157,7 +290,10 @@ async function stTempHistLoad() {
from = new Date(dateStr + 'T00:00:00+09:00');
to.setTime(from.getTime() + 86400000 - 1000); // 23:59:59
}
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)
histUrl += `&product=${encodeURIComponent(stTempSelectedProduct)}`;
const url = histUrl;
console.log('[steam] hist URL:', url);
try {
const h = await api('GET', url);
@@ -204,7 +340,8 @@ function stTempScrub() {
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';
@@ -264,8 +401,8 @@ function stRenderTemp(snap) {
title: {
text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
subtext: snap
? `매칭제품 ${snap.matchedProduct || ''} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}`
: `매칭제품 ${stTempLive.matchedProduct || ''} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`,
? `기준제품 ${snap.matchedProduct || '미선택'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}`
: `기준제품 ${stTempLive.matchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`,
textStyle: { color: '#ccc', fontSize: 13 },
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
tooltip: { trigger: 'axis' },

View File

@@ -63,7 +63,10 @@
<div class="st-bt-bar">
컬럼: <select id="st-temp-col" class="inp" style="width:120px"></select>
<button class="btn-a" id="st-temp-load">조회</button>
<span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span>
<select id="st-temp-prod-sel" class="inp" style="width:130px;font-size:11px">
<option value="">제품 선택…</option>
</select>
<button class="btn-a-s" id="st-temp-prod-cfg" title="제품명 설정">⚙ 제품명</button>
<span id="st-temp-vac" class="st-badge st-badge-conf">진공 —</span>
<span style="margin-left:12px;font-size:11px;color:#666">일자:</span>
<input type="date" id="st-temp-date" class="inp" style="width:140px;font-size:11px">
@@ -91,6 +94,33 @@
</div>
<div class="st-bt-meta" id="st-temp-meta" style="display:none"></div>
<!-- ═══ 제품명 설정 모달 ═══ -->
<div id="st-prod-modal" class="st-modal hidden">
<div class="st-modal-box">
<div class="st-modal-hd">
<strong>제품명 설정</strong>
<span id="st-prod-modal-col" class="st-badge st-badge-mode"></span>
<button id="st-prod-modal-x" class="st-modal-x" title="닫기"></button>
</div>
<div class="st-modal-bd">
<div class="st-modal-note">
온도 클러스터(reb-A 기준)에 운전 제품명을 지정합니다. 비워두면 자동 라벨(P0/P1…)로 표시됩니다.
제품명은 직접 입력하거나 목록에서 고를 수 있습니다.
</div>
<table class="st-prod-tbl">
<thead><tr><th>클러스터</th><th>reb-A(℃)</th><th>진공</th><th>샘플수</th><th>제품명</th></tr></thead>
<tbody id="st-prod-rows"></tbody>
</table>
<datalist id="st-prod-suggest"></datalist>
<div id="st-prod-modal-msg" class="st-modal-msg"></div>
</div>
<div class="st-modal-ft">
<button id="st-prod-modal-cancel" class="btn-a-s">취소</button>
<button id="st-prod-modal-save" class="btn-a">저장</button>
</div>
</div>
</div>
</div>
<!-- ═══ 백테스트 패널 ═══ -->
@@ -198,5 +228,25 @@
.st-meta-flow th small { display:block; font-size:9px; color:#555; margin-top:2px; text-align:left; }
.st-meta-flow td { padding:2px 6px; border-bottom:1px solid #1a2a3a; color:#ccc; text-align:right; white-space:nowrap; }
.st-meta-flow td:first-child { color:#666; font-weight:bold; text-align:left; }
/* ── 제품명 설정 모달 ── */
.st-modal { position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; }
.st-modal.hidden { display:none; }
.st-modal-box { background:#0d1622; border:1px solid #2a3a4a; border-radius:6px; width:520px; max-width:92vw; max-height:86vh; display:flex; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,.5); }
.st-modal-hd { display:flex; align-items:center; gap:10px; padding:12px 16px; border-bottom:1px solid #1e2a3a; }
.st-modal-hd strong { font-size:14px; color:#cde; }
.st-modal-x { margin-left:auto; background:none; border:none; color:#789; font-size:16px; cursor:pointer; }
.st-modal-x:hover { color:#f66; }
.st-modal-bd { padding:14px 16px; overflow:auto; }
.st-modal-note { font-size:11px; color:#789; margin-bottom:10px; line-height:1.5; }
.st-prod-tbl { width:100%; border-collapse:collapse; font-size:12px; }
.st-prod-tbl th { color:#789; font-weight:normal; text-align:left; padding:4px 8px; border-bottom:1px solid #2a3a4a; font-size:11px; }
.st-prod-tbl td { padding:5px 8px; border-bottom:1px solid #16202c; color:#cde; }
.st-prod-tbl td.num { color:#8cf; font-variant-numeric:tabular-nums; }
.st-prod-tbl input.inp { width:140px; font-size:12px; padding:3px 6px; }
.st-modal-msg { margin-top:10px; font-size:11px; min-height:15px; }
.st-modal-msg.ok { color:#6d6; }
.st-modal-msg.err { color:#f66; }
.st-modal-ft { display:flex; gap:8px; justify-content:flex-end; padding:12px 16px; border-top:1px solid #1e2a3a; }
</style>