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:
845
docs/steam-온도프로파일-제품-사용자정의-수정방안.md
Normal file
845
docs/steam-온도프로파일-제품-사용자정의-수정방안.md
Normal 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,'&').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)
|
||||||
|
|
||||||
|
```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(블로커) 해소 전에는 빌드/동작 불가.*
|
||||||
@@ -61,7 +61,8 @@ def build(prefix, stable_from=None, stable_to=None):
|
|||||||
stages = {s: {"median": round(float(g[s].median()), 2),
|
stages = {s: {"median": round(float(g[s].median()), 2),
|
||||||
"std": round(float(g[s].std()), 2)} for s in STAGES}
|
"std": round(float(g[s].std()), 2)} for s in STAGES}
|
||||||
products.append({
|
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),
|
"n_rows": len(g),
|
||||||
"span_AD": round(float((g["reb_temp"] - g["T_D"]).median()), 2),
|
"span_AD": round(float((g["reb_temp"] - g["T_D"]).median()), 2),
|
||||||
"vacuum": {"median": round(float(g["vacuum"].median()), 2),
|
"vacuum": {"median": round(float(g["vacuum"].median()), 2),
|
||||||
|
|||||||
12
scripts/analysis/product_labels.json
Normal file
12
scripts/analysis/product_labels.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"C-6111": [
|
||||||
|
{
|
||||||
|
"cluster": "P0",
|
||||||
|
"name": "PGMEA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cluster": "P1",
|
||||||
|
"name": "PMA"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -182,14 +182,22 @@ public sealed class SteamAdvisorController : ControllerBase
|
|||||||
// realtime 단별 온도/진공 vs 기준밴드({col}_tempref.json) → 제품매칭 + z-score 이격.
|
// realtime 단별 온도/진공 vs 기준밴드({col}_tempref.json) → 제품매칭 + z-score 이격.
|
||||||
// col 규약 = 컬럼키("C-6111","C-6211","C-8111",...).
|
// col 규약 = 컬럼키("C-6111","C-6211","C-8111",...).
|
||||||
[HttpGet("tempprofile/{col}")]
|
[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);
|
var tref = await LoadTempRef(col);
|
||||||
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
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 cur = await FetchRealtimeValues(col, tref);
|
||||||
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
|
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected);
|
||||||
if (prod is null) tref = tref with { }; // no-op, keep tref for metadata
|
|
||||||
|
|
||||||
var flow = await FetchFlowValues(col);
|
var flow = await FetchFlowValues(col);
|
||||||
var bases = FlowBases(ToSuffix(col));
|
var bases = FlowBases(ToSuffix(col));
|
||||||
@@ -198,13 +206,20 @@ public sealed class SteamAdvisorController : ControllerBase
|
|||||||
{
|
{
|
||||||
column = tref.Column,
|
column = tref.Column,
|
||||||
period = tref.Period,
|
period = tref.Period,
|
||||||
matchedProduct = prod?.Label,
|
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||||
nProducts = tref.NProducts,
|
nProducts = tref.NProducts,
|
||||||
stages,
|
stages,
|
||||||
vacuum,
|
vacuum,
|
||||||
spanAD,
|
spanAD,
|
||||||
spanRef = prod?.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,
|
timestamp = DateTime.UtcNow,
|
||||||
flow = new {
|
flow = new {
|
||||||
feed = flow["feed"],
|
feed = flow["feed"],
|
||||||
@@ -232,13 +247,21 @@ public sealed class SteamAdvisorController : ControllerBase
|
|||||||
public async Task<IActionResult> TempProfileHistory(
|
public async Task<IActionResult> TempProfileHistory(
|
||||||
string col,
|
string col,
|
||||||
[FromQuery] DateTime? from = null,
|
[FromQuery] DateTime? from = null,
|
||||||
[FromQuery] DateTime? to = null)
|
[FromQuery] DateTime? to = null,
|
||||||
|
[FromQuery] string? product = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tref = await LoadTempRef(col);
|
var tref = await LoadTempRef(col);
|
||||||
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
|
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;
|
to ??= DateTime.UtcNow;
|
||||||
from ??= to.Value.AddHours(-1);
|
from ??= to.Value.AddHours(-1);
|
||||||
// 24시간 초과 방지 (전일 조회 대응)
|
// 24시간 초과 방지 (전일 조회 대응)
|
||||||
@@ -257,7 +280,6 @@ public sealed class SteamAdvisorController : ControllerBase
|
|||||||
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
|
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
|
||||||
|
|
||||||
var snapshots = new List<object>();
|
var snapshots = new List<object>();
|
||||||
var products = tref.Products;
|
|
||||||
|
|
||||||
static double? V(IReadOnlyDictionary<string, string?> d, string tag)
|
static double? V(IReadOnlyDictionary<string, string?> d, string tag)
|
||||||
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
|
=> 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;
|
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)
|
object FlowRole(string baseTag, string role)
|
||||||
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
|
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
|
||||||
snapshots.Add(new
|
snapshots.Add(new
|
||||||
{
|
{
|
||||||
ts = row.TimeBucket,
|
ts = row.TimeBucket,
|
||||||
matchedProduct = prod?.Label,
|
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
|
||||||
stages,
|
stages,
|
||||||
vacuum,
|
vacuum,
|
||||||
spanAD,
|
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)
|
private async Task<TempRef?> LoadTempRef(string col)
|
||||||
{
|
{
|
||||||
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
|
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(
|
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;
|
// 자동매칭 폐지 — 운전원이 고른 제품만 기준밴드로 사용. 미선택(null)이면 밴드 없음.
|
||||||
if (cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0)
|
TempProduct? prod = selected;
|
||||||
prod = tref.Products
|
|
||||||
.OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb))
|
|
||||||
.First();
|
|
||||||
|
|
||||||
static double? Z(double? c, TempStat? r)
|
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;
|
=> (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 sealed record TempProduct
|
||||||
{
|
{
|
||||||
public string Label { get; init; } = "";
|
public string Label { get; init; } = "";
|
||||||
|
public string? Name { get; init; } // MSDS 화학물질명 (null이면 Label 폴백)
|
||||||
[JsonPropertyName("n_rows")] public int NRows { get; init; }
|
[JsonPropertyName("n_rows")] public int NRows { get; init; }
|
||||||
[JsonPropertyName("span_AD")] public double SpanAD { get; init; }
|
[JsonPropertyName("span_AD")] public double SpanAD { get; init; }
|
||||||
public TempStat Vacuum { get; init; } = new();
|
public TempStat Vacuum { get; init; } = new();
|
||||||
|
|||||||
@@ -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 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 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,'"');
|
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
||||||
|
// ── 제품 선택 상태 ── ('' = 미선택, 자동매칭 없음)
|
||||||
|
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 stFmtDT = ts => {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
const kst = new Date(d.getTime() + 9 * 3600000);
|
const kst = new Date(d.getTime() + 9 * 3600000);
|
||||||
@@ -93,7 +120,22 @@ function stTempInit() {
|
|||||||
const sel = document.getElementById('st-temp-col');
|
const sel = document.getElementById('st-temp-col');
|
||||||
if (sel && !sel.options.length)
|
if (sel && !sel.options.length)
|
||||||
sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
|
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').value = new Date().toISOString().split('T')[0];
|
||||||
document.getElementById('st-temp-date').onchange = stTempLoad;
|
document.getElementById('st-temp-date').onchange = stTempLoad;
|
||||||
document.getElementById('st-temp-load').onclick = stTempLoad;
|
document.getElementById('st-temp-load').onclick = stTempLoad;
|
||||||
@@ -108,6 +150,75 @@ function stTempInit() {
|
|||||||
slider.addEventListener('input', stTempScrub);
|
slider.addEventListener('input', stTempScrub);
|
||||||
slider.addEventListener('change', stTempScrub);
|
slider.addEventListener('change', stTempScrub);
|
||||||
document.getElementById('st-scrub-live').onclick = stTempGoLive;
|
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() {
|
async function stTempLoad() {
|
||||||
@@ -116,20 +227,42 @@ async function stTempLoad() {
|
|||||||
stTempLive = null;
|
stTempLive = null;
|
||||||
stTempIsLive = true;
|
stTempIsLive = true;
|
||||||
try {
|
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 (_) {}
|
} catch (_) {}
|
||||||
await stTempHistLoad();
|
await stTempHistLoad();
|
||||||
stTempTimer = setInterval(stTempTick, 5000);
|
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() {
|
async function stTempTick() {
|
||||||
const col = document.getElementById('st-temp-col').value;
|
const col = document.getElementById('st-temp-col').value;
|
||||||
const st = document.getElementById('st-temp-status');
|
const st = document.getElementById('st-temp-status');
|
||||||
try {
|
try {
|
||||||
stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
|
let url = `/api/steam/tempprofile/${col}`;
|
||||||
if (stTempIsLive) {
|
if (stTempSelectedProduct)
|
||||||
stRenderTemp();
|
url += `?product=${encodeURIComponent(stTempSelectedProduct)}`;
|
||||||
}
|
stTempLive = await api('GET', url);
|
||||||
|
// F5: 드롭다운 옵션 갱신은 최초 1회만 (매 틱 innerHTML 재작성 금지)
|
||||||
|
if (stTempLive && stTempIsLive) stRenderTemp();
|
||||||
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
|
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
st.textContent = '오류: ' + e.message;
|
st.textContent = '오류: ' + e.message;
|
||||||
@@ -157,7 +290,10 @@ async function stTempHistLoad() {
|
|||||||
from = new Date(dateStr + 'T00:00:00+09:00');
|
from = new Date(dateStr + 'T00:00:00+09:00');
|
||||||
to.setTime(from.getTime() + 86400000 - 1000); // 23:59:59
|
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);
|
console.log('[steam] hist URL:', url);
|
||||||
try {
|
try {
|
||||||
const h = await api('GET', url);
|
const h = await api('GET', url);
|
||||||
@@ -204,7 +340,8 @@ function stTempScrub() {
|
|||||||
function stTempUpdateBadges(src) {
|
function stTempUpdateBadges(src) {
|
||||||
const s = src || stTempLive;
|
const s = src || stTempLive;
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—');
|
|
||||||
|
// 제품명은 드롭다운이 표시 — 별도 배지 없음 (중복 제거)
|
||||||
const vb = document.getElementById('st-temp-vac');
|
const vb = document.getElementById('st-temp-vac');
|
||||||
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
|
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
|
||||||
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||||
@@ -264,8 +401,8 @@ function stRenderTemp(snap) {
|
|||||||
title: {
|
title: {
|
||||||
text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
|
text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
|
||||||
subtext: snap
|
subtext: snap
|
||||||
? `매칭제품 ${snap.matchedProduct || '—'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.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 ? ' ⚠' : ''}`,
|
: `기준제품 ${stTempLive.matchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`,
|
||||||
textStyle: { color: '#ccc', fontSize: 13 },
|
textStyle: { color: '#ccc', fontSize: 13 },
|
||||||
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
|
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
|
|||||||
@@ -63,7 +63,10 @@
|
|||||||
<div class="st-bt-bar">
|
<div class="st-bt-bar">
|
||||||
컬럼: <select id="st-temp-col" class="inp" style="width:120px"></select>
|
컬럼: <select id="st-temp-col" class="inp" style="width:120px"></select>
|
||||||
<button class="btn-a" id="st-temp-load">조회</button>
|
<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 id="st-temp-vac" class="st-badge st-badge-conf">진공 —</span>
|
||||||
<span style="margin-left:12px;font-size:11px;color:#666">일자:</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">
|
<input type="date" id="st-temp-date" class="inp" style="width:140px;font-size:11px">
|
||||||
@@ -91,6 +94,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="st-bt-meta" id="st-temp-meta" style="display:none"></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>
|
</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 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 { 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-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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user