feat: FF 온도계 위젯 + 민감단 모듈1 T_C 유지 SP 제안

위젯: AdvisoryResult에 config 시각화 필드 추가, ffThermometer() 세로 온도계 위젯 구현 (4단 마커+sweet spot 밴드+하한선+C편차 화살표+옵션 푸터)

모듈1: FeedforwardEngine SteamAdvisor DI + ResolveTcTemp, Normal 모드에서 T_C 유지 Steam OP 제안 (SteamAdvisor Predict + FB trim gain-0.6/dwell20분)
This commit is contained in:
windpacer
2026-06-07 08:22:43 +09:00
parent a32ec18580
commit 1f989bd861
93 changed files with 439859 additions and 23 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,115 @@
# 작업플랜 — FF 카드 세로 온도계 위젯 (T_C sweet spot) (2026-06-07)
> 상위: `docs/작업지시서-STEAM-SP-FF통합.md` ③단계(UI 행)의 시각화 컴포넌트.
> 설계 맥락: SteamAdvisor(steam=f(feed,product,T_C)→OP)를 FF 추종 프레임에 통합 중.
## 진단 (2026-06-07, diagnosis-checklist.md 적용)
| # | 심각도 | 항목 | 근거 | 파일·라인 |
|---|--------|------|------|-----------|
| 1 | 🟠 MED | **위젯이 Config 데이터에 접근할 방법 미정의** | 계획은 `tcReturnTcTarget`/`tcReturnTcBand`(밴드)와 `sensitiveTrayTag`(C 마커 식별)를 config 응답에서 가져온다고 하나, `ffCard(c)`는 오직 `/api/ff/advisory``MapColumn`만 인자로 받음. Config는 별도 `/api/ff/config`로 로드되어 `ff-cfg-list`에만 렌더 — `ffCard` 스코프에 config 데이터가 없다. 밴드 생략(마커만)으로 우회는 가능하지만, C 마커를 tag name 매칭 없이 index 추정(70%)에만 의존하면 컬럼별 TempTags 개수·순서 차이 시 오표시. | `wwwroot/js/ff.js:190` (`ffCard(c)` 호출, `c`는 advisory 전용), 대비 `FeedforwardController.cs:381-459` (MapColumn에 config 필드 없음), 계획 §단계-1 |
| 2 | 🟡 LOW | **temps 순서 가정이 TempTags 설정 순서에 의존** | 계획(line 36)은 `reb-A=temps[0]`, `D=temps[n-1]`를 가정하나, 이 순서는 DB `TempTags` 배열 순서에 전적으로 의존. `BuildTemps`(FeedforwardEngine.cs:177)는 주석으로 "하단→상단 순서"를 전제하지만 강제하지 않음. 운전자/엔지니어가 설정 화면에서 태그 순서를 잘못 입력하면 마커 라벨이 온도와 불일치. 계획(line 72-73)이 "향후 config화"로 명시하나 first-cut에서도 조치 필요. | `FeedforwardEngine.cs:177-205` (BuildTemps, 순서 검증 없음), 계획 §현자산 line 36, §주의 line 72-73 |
| 3 | 🟡 LOW | **폴링 주기 불일치 (5s vs 3s)** | 검증기준(line 64)은 "5초 폴링마다"라고 명시했으나 `ffInit`(ff.js:25)는 `setInterval(ffLoadDash, 3000)`으로 3초. 온도계 갱신 주기도 동일 타이머를 공유하므로 문서와 실제 동작 간 차이 발생. 기능상 문제는 없으나 혼선 가능. | 계획 §검증기준 line 64, 대비 `ff.js:25` |
| 4 | 🟡 LOW | **C-index 70% fallback이 4-Temp 가정에 묶임** | 계획(line 51)은 `sensitiveTrayTag` 매칭 실패 시 "인덱스 추정(70%)"으로 C를 식별. C-6111은 4개 temps(index=2로 정확)이나, 3개 temps면 index=2(마지막), 5개면 index=3(80% 위치)으로 의도한 민감단과 다를 수 있음. `sensitiveTrayTag`가 config에 있고 advisory에 없으므로 사실상 항상 fallback에 의존할 가능성. | 계획 §단계-1 line 51, `FeedforwardController.cs:450-456` (MapColumn temps에 tag name 포함 — 매칭 가능은 하나 config 필드 없음) |
### 교차검증 종합
- Q1 (이미 수정됨?) — 해당 없음 (plan 문서, 아직 구현 전)
- Q2 (다른 레이어 처리?) — #1: advisory 응답에 `sensitiveTrayTag`를 추가하거나, ffLoadDash가 config를 병렬 로드해 ffCard에 넘겨야 함. 현재 어느 쪽도 아님.
- Q3 (의도적 설계?) — #1은 설계 누락(구현 시 발견될 문제). #2·#4는 계획이 인지한 범위 내 한계. #3은 단순 오기.
- Q4 (재현 시나리오?) — #1: ffThermometer 구현 시 sweet spot 밴드 미표시 또는 C 마커 오식별. #2: TempTags 순서 실수 시 마커 라벨 혼동.
### 연계 진단 — `작업플랜-민감단온도-전환복귀제어.md`와의 통합
| # | 심각도 | 항목 | 근거 | 관련 Plan |
|---|--------|------|------|-----------|
| 5 | 🟠 MED | **`tcReturnTcTarget/Band`가 시각화와 제어게이트를 겸함 — 안전 문제** | 위젯은 `tcReturnTcTarget ± tcReturnTcBand`를 sweet spot 시각화 밴드로 사용(계획 line 37-38). 동시에 엔진 `ApplyRecovery()`(FeedforwardEngine.cs:497-498)는 같은 필드를 **복귀(Returning) 게이트 조건**으로 평가함. 운전자가 시각화 편의를 위해 밴드를 넓히면 복귀 게이트가 느슨해져 T_C가 완전히 안정되지 않았는데 Normal 복귀 허용 위험. 두 용도가 동일 config 필드를 공유하는 것은 설계상 충돌. | 민감단 §모듈3 line 57, FF위젯 §현자산 line 37-38, `FeedforwardEngine.cs:497-498` |
| 6 | 🟠 MED | **위젯에 `TempLowLimit` 미반영 — T_C 하한 경고 누락** | 엔진은 `TempLowLimit`(기본 -1e9=비활성)으로 T_C 하한 이탈을 감시(sigTLow, FeedforwardEngine.cs:444), 전환류 진입 트리거로 사용. 위젯은 sweet spot 밴드만 표시하고 `TempLowLimit` 경계를 표시하지 않음. `TempLowLimit``tcReturnTcTarget - tcReturnTcBand`보다 높게 설정된 경우(예: 목표±1℃ 대신 하한 -3℃), C가 sweet spot 밴드 안에 있어 초록이지만 실제로는 하한 임박 — 운전자 오인 유발. 위젯 온도계에 하한 임계선(적색 점선)을 추가하거나, 최소한 위험 상태 배지 연동 필요. | 민감단 §모듈2 line 51, FF위젯 §단계-2 line 55-56, `FeedforwardEngine.cs:444` |
| 7 | 🟡 LOW | **`EnteredByTcLow`가 advisory에 미노출 — 전환류 원인 표시 불가** | 엔진은 `st.EnteredByTcLow`(FeedforwardEngine.cs:46)로 전환류 진입이 T_C 하한 때문인지 기록하고, 복귀 게이트 분기(같은 파일 line 502)에 사용. 이 정보가 `AdvisoryResult``MapColumn`에 없어 위젯이 "T_C 하한 진입" vs "물질수지/ΔP 진입"을 구분해 표시할 수 없음. | 민감단 §큰그림, `FeedforwardEngine.cs:46/469/502`, `FeedforwardController.cs:421-459` (MapColumn) |
| 8 | 🟡 LOW | **`TempLowLimit`도 advisory에 미노출 — 위젯이 하한 임계선 표시 불가** | `MapColumn`(FeedforwardController.cs:421-459)에 `tempLowLimit` 누락. 위젯이 하한 임계선을 그리려면 이 값이 필요. `tempLowLimit`은 엔진에서 읽지만 advisory 응답에 포함되지 않음. #6 해결의 선행조건. | `FeedforwardController.cs:421-459` (MapColumn), `ColumnConfig.TempLowLimit` |
| 9 | 🟡 LOW | **온도계 초록(정상)과 모드배지 간 시차 혼동 가능** | 위젯은 T_C가 밴드 안이면 즉시 초록(◉) 표시. 엔진은 Returning 게이트 조건(T_C in-band + reb-A in-band + ΔT(A-D) 안정)이 `RecoverySettleSec`(기본 1800s=30분) 지속되어야 Normal로 전이(FeedforwardEngine.cs:504). 따라서 운전자는 온도계는 초록인데 모드배지는 "전환류 평형대기 1200/1800s"인 상황을 30분간 보게 됨. 정상 동작이나 사전 안내 없으면 혼란. | 민감단 §모듈3 line 57-58, `ff.js:320-325` (모드배지), `FeedforwardEngine.cs:497-504` |
| 10 | 🟡 LOW | **온도계 마커가 Recovering 중에도 살아있어야 하나 미명시** | 전환류(Recovering) 중에도 T_C 온도는 유효하며, 운전자는 T_C가 회복되는 과정을 실시간으로 봐야 복귀 게이트 진행을 납득 가능. 위젯 계획(line 76)은 "표시 전용"이라 명시했으나 Recovering 중에도 위젯이 활성 상태를 유지하는지 미기재. `c.temps`가 Recovering 중에도 정상 제공되므로 별도 처리 불필요하나, 명시적 언급이 없으면 구현 시 실수로 숨길 가능성. | FF위젯 §주의 line 76, §단계-1 line 51 (c.temps 조건부 표시) |
| 11 | 🟡 LOW | **하강 램프 시각화 미지원** | 민감단 계획(§4-A)은 FeedRampAdvisor 하강 램프 추가를 명시하나, `FeedRampCalculator.Compute()`(FeedRampCalculator.cs:87-88)는 아직 하강 미구현. 위젯은 이와 무관하지만, 램프 진행 중 온도계가 어떻게 변화하는지(특히 하강 램프가 T_C에 미치는 영향)를 보여주지 않으면 운전자 피드백 루프 불완전. 단, FF위젯 계획이 "표시 전용"이므로 직접 구현 범위 밖 — 인지하고 넘어갈 것. | 민감단 §4-A line 62, `FeedRampCalculator.cs:87-88` |
### 연계 교차검증
- Q1 (이미 수정됨?) — #11의 하강 램프는 `FeedRampCalculator`에 여전히 미구현 상태 (Q1 통과)
- Q2 (다른 레이어 처리?) — #5: 두 plan 모두 동일 config 필드를 각자 용도로 참조하므로, 다른 레이어에서 해소되지 않음. #7·#8: advisory DTO가 누락 — MapColumn에 필드 추가 필요.
- Q3 (의도적 설계?) — #5: 의도적이 아니라 간과된 설계 충돌. #9: 정상 동작이나 UX 관점에서 명시 필요. #10: 위젯 계획에 명시적 언급 없음.
- Q4 (재현 시나리오?) — #5: 운전자가 설정 화면에서 밴드를 1.0→2.0으로 늘리면, 복귀 게이트가 T_C 편차 ±1℃→±2℃로 완화되어 T_C 미회복 상태에서 Normal 조기 복귀 가능. #6: `TempLowLimit`=207℃, sweet spot=209±1℃일 때 T_C=207.5℃는 밴드 밖이지만 위젯이 하한 임계를 표시하지 않아 "노랑(▲)"만 보고 운전자 대응 늦어짐. #9: 초록 온도계 + "전환류 중" 배지를 30분간 보며 혼란.
## 목적
유량권장(FF) C-6111 카드 **옆에 세로 온도계(thermometer) 위젯**을 붙여,
민감단 온도 T_C가 **제품별 목표(설정값) ± 허용대역(sweet spot)** 대비
처지는지(▼) / 올라가는지(▲)를 운전자가 한눈에 보게 한다.
reb-A·B·C·D 4단을 같은 온도축에 함께 표시해 컬럼 단면 프로파일까지 한 그림에 담는다.
## 선택된 형태 (결정됨)
**세로 온도계 스냅샷** (트렌드 막대/스크롤 아님 — steam 패널 uPlot 트렌드와 중복 회피).
- 세로축 = 온도(℃). "처짐/상승" = 마커의 아래/위 이동으로 직결.
- sweet spot = 반투명 두꺼운 밴드(목표 ± 허용). 밴드 안에 C가 있으면 정상.
- reb-A(0%·bottom)·B(50%)·C(70%·민감단)·D(90%·top) 4개 마커. 각 단 라벨에 % 표기.
- C가 밴드 하한 아래로 처지면 ▼ 적색, 상한 위면 ▲ 주황, 안이면 초록.
- (옵션 푸터) 신뢰도 + 권장 TICA SP — 백엔드 공백 해소 후.
```
T_C 온도계 (℃)
222 ┤◀ reb-A 0%(bottom)
213 ┤◀ B 50%
░░░░░ 211 sweet 상한
░░◉░░ 209 ◀ C 70% ★목표
░░░░░ 207 sweet 하한
201 ┤◀ D 90%(top)
─────────────────
conf HIGH · TICA SP→209 ← (옵션, 백엔드 공백)
```
## 현 자산 (재활용, 중복금지)
프론트(`/api/ff/advisory` + `/api/ff/config`)에 이미 도달하는 데이터:
- **현재 단별 온도** — `c.temps[]` = `{tag, raw, pct, good}`, **하단→상단 순서**
(FeedforwardEngine.BuildTemps 주석: `tica-6111a(0%), ti-6111b, ti-6111c(민감단), ti-6111d(top)`).
→ reb-A=`temps[0]`, C=민감단=`sensitiveTrayTag` 매칭(또는 70% 위치), D=`temps[n-1]`.
- **sweet spot 밴드** — `/api/ff/config``tcReturnTcTarget`(목표℃) ± `tcReturnTcBand`(반폭, 기본 1.0)
(FeedforwardController.cs:222-223에서 이미 직렬화). 운전자 입력 설정값 = 이 값.
- **상태색 보조** — `c.tempProfileState`, `c.tempSpan`.
- 카드 렌더 진입점: `wwwroot/js/ff.js` `ffCard(c)` (257행~). CSS: 동일 ff 스타일시트.
## 백엔드 공백 (옵션 푸터에만 필요) ⚠
스팀 제안(`SteamAdvisor.Predict``RecOp/RecSteam/Confidence/Ood`)이
**FF advisory DTO(`MapColumn`)에 아직 미노출** (Feedforward 폴더 grep 0건).
- 온도계 본체(밴드+4마커+처짐/상승색)는 **공백과 무관하게 1차 구현 가능**.
- 푸터의 "conf · TICA SP→" 표시는 작업지시서-STEAM-SP-FF통합 ①단계
(`AdvisoryResult`에 Steam 필드 추가 → `MapColumn`에 노출)가 선행돼야 함.
## 단계
1. **데이터 셀렉터 (ff.js)**`c` 한 개에서 온도계 입력 추출:
- `temps = c.temps`(없으면 위젯 미표시), C = `sensitiveTrayTag`로 매칭하되 실패 시 인덱스 추정(70%).
- 밴드 = config의 `tcReturnTcTarget`/`tcReturnTcBand`(없으면 밴드 생략, 마커만).
- 스케일 = `[min(저온, 밴드하한), max(고온, 밴드상한)]` + 여유 5% 패딩.
2. **렌더 (ff.js)**`ffThermometer(c)` 함수 신설. SVG 또는 div+CSS 세로 막대.
- 밴드 사각형(반투명), 4단 마커(틱+라벨+값), C 강조(◉).
- C 위치 판정 → 클래스 `ff-tc-low/ok/high`로 색/화살표.
3. **카드 결합 (ff.js)**`ffCard` 반환 마크업에 `ffThermometer(c)`를 카드 우측 컬럼으로 삽입
(기존 카드를 flex 2열: 좌=표/배지, 우=온도계).
4. **CSS**`.ff-thermo`(세로 트랙), `.ff-thermo-band`, `.ff-thermo-mark`, `.ff-tc-low/ok/high`.
live 갱신 시 `top` 트랜지션으로 마커가 부드럽게 이동(0.3s).
5. **(옵션) 푸터** — ①단계 백엔드 노출 후 `conf` + `recSteam`/`recOp` 한 줄. 없으면 숨김.
## 검증기준
- C-6111 카드 옆에 온도계 표시, 5초 폴링마다 4단 마커가 live 이동.
- T_C가 밴드 안 → 초록·◉ 밴드 중앙 근처. 밴드 아래로 내리면 ▼ 적색, 위면 ▲ 주황.
- `tcReturnTcTarget` 미설정 컬럼 → 밴드 없이 마커만(에러 없음).
- `c.temps` 없음/stale → 위젯 graceful 미표시 또는 회색.
- (옵션) ①단계 적용 시 푸터에 conf+SP 표시, 미적용 시 푸터 숨김.
## 주의
- range는 realtime/config live값 사용 — xlsx·하드코딩 금지(메모리·작업지시서 일관).
- 단 라벨 %(0/50/70/90)는 **C-6111 레이아웃 기준 표시값**. 컬럼별 상이 시 향후 config화
(현재 BuildTemps 주석도 C-6111 고정 — 일반화는 별도 작업).
- reb-A가 온도축 최상단(최고온)에 오는 건 정상(물리 높이가 아닌 온도축이므로). 라벨로 명시.
- 6차만 C3 online → first-cut은 C-6111. 9·10차(C4 미연결)는 데이터 도달 시 자동 적용.
- closed-loop 강제·자동쓰기와 무관한 **표시 전용** 위젯(메모리: 조건 실변동중 — 시각화만).
## 권장순서
①셀렉터+렌더(공백 무관) → ②CSS → ③카드결합 → ④(백엔드 ①단계 후)푸터.
①~③은 STEAM-SP-FF통합 백엔드와 독립 병행 가능.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

View File

@@ -144,11 +144,20 @@ public sealed record AdvisoryResult(
public string? LowerFrontState { get; init; } // 하부 ΔT(CB): 정상/상승/하강/정착대기
public double? UpperFrontMetric { get; init; }
public double? LowerFrontMetric { get; init; }
public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
// Phase II: auto-write 상태
public bool AutoWriteActive { get; init; }
public double? WriteGuardBlockedSp { get; init; } // WriteGuard가 차단한 SP값(표시용)
public string? WriteGuardReason { get; init; } // 차단 사유
// 작업플랜-FF온도계위젯: 위젯 렌더용 config 필드 (advisory에 함께 전달)
public string? SensitiveTrayTag { get; init; } // 민감트레이 태그명
public double? TcReturnTcTarget { get; init; } // sweet spot 목표온도(℃)
public double? TcReturnTcBand { get; init; } // sweet spot 반폭(℃)
public double? TempLowLimit { get; init; } // T_C 하한 임계(℃)
// 작업플랜-민감단온도 모듈1: T_C 유지 Steam OP 제안
public double? SteamRecOp { get; init; } // 권장 Steam OP(%)
public string? SteamConfidence { get; init; } // HIGH/LOW_OOD/N/A
public double? TcDeviation { get; init; } // T_C 편차(℃) = actual - target
}
public sealed record TempPoint(string Tag, double Raw, double Pct, bool Good);

View File

@@ -447,6 +447,13 @@ public sealed class FeedforwardController : ControllerBase
autoWriteActive = r.AutoWriteActive,
writeGuardBlockedSp = r.WriteGuardBlockedSp,
writeGuardReason = r.WriteGuardReason,
sensitiveTrayTag = r.SensitiveTrayTag,
tcReturnTcTarget = r.TcReturnTcTarget,
tcReturnTcBand = r.TcReturnTcBand,
tempLowLimit = r.TempLowLimit,
steamRecOp = r.SteamRecOp,
steamConfidence = r.SteamConfidence,
tcDeviation = r.TcDeviation,
temps = r.Temps?.Select(t => new
{
tag = t.Tag,

View File

@@ -137,3 +137,26 @@
/* WP5 3단계: 조성 입력 폼 */
.ff-comp-body { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.ff-comp-list { width:100%; margin-top:4px; font-size:12px; color:#94a3b8; }
/* ── FF T_C 온도계 위젯 (sweet spot thermometer) ──────────────── */
.ff-has-thermo{display:flex;flex-direction:row;gap:0}
.ff-card-main{flex:1;min-width:0}
.ff-thermo{display:flex;flex-direction:column;width:110px;flex-shrink:0;border-left:1px solid var(--bd);padding:4px 0 4px 8px}
.ff-thermo-hd{font-size:10px;font-weight:700;color:var(--t2);text-align:center;margin-bottom:2px;letter-spacing:-.3px}
.ff-thermo-body{flex:1;position:relative;min-height:180px}
.ff-thermo-track{position:absolute;inset:4px 0 4px 28px}
.ff-thermo-tick{position:absolute;left:-28px;right:0;height:0;border-top:1px solid rgba(255,255,255,.08);pointer-events:none}
.ff-thermo-tick span{position:absolute;right:calc(100% + 3px);top:-6px;font-size:9px;color:var(--t3);white-space:nowrap;font-variant-numeric:tabular-nums}
.ff-thermo-band{position:absolute;left:0;right:4px;background:rgba(100,200,100,.13);border:1px solid rgba(100,200,100,.25);border-radius:2px;pointer-events:none}
.ff-thermo-limit{position:absolute;left:-4px;right:0;height:0;border-top:2px dashed #ff5252;pointer-events:none}
.ff-thermo-mark{position:absolute;left:0;right:0;height:0;display:flex;align-items:center;gap:3px;pointer-events:none;margin-top:-6px}
.ff-thermo-dot{display:flex;align-items:center;justify-content:center;width:10px;height:10px;font-size:6px;color:var(--t2);background:var(--bg2);border:1px solid var(--t3);border-radius:50%;line-height:1;margin-left:2px;z-index:1}
.ff-thermo-c{width:16px;height:16px;font-size:12px;font-weight:700;background:var(--bg1);border-width:2px;z-index:2}
.ff-thermo-c.ff-tc-low{border-color:#ff5252;color:#ff5252}
.ff-thermo-c.ff-tc-ok{border-color:#69f0ae;color:#69f0ae}
.ff-thermo-c.ff-tc-high{border-color:#ffb74d;color:#ffb74d}
.ff-thermo-lbl{display:flex;flex-direction:column;gap:0;font-size:10px;color:var(--t0);line-height:1.2;white-space:nowrap}
.ff-thermo-lbl small{font-size:8px;color:var(--t3);line-height:1}
.ff-thermo-arrow{text-align:center;font-size:16px;line-height:1;padding:1px 0}
.ff-thermo-ft{font-size:9px;color:var(--t2);text-align:center;line-height:1.3;padding:2px 0;border-top:1px solid var(--bd);margin-top:2px}
.ff-thermo-ft .ff-tc-ok{color:#69f0ae}.ff-thermo-ft .ff-tc-low{color:#ff5252}

View File

@@ -254,6 +254,82 @@ function ffCardRampStart(colId, currentFeed) {
.then(() => ffLoadDash()).catch(e => alert('램프 시작 실패: ' + e.message));
}
// ── FF 온도계 위젯 (T_C sweet spot) ──────────────────────────────
function ffThermometer(c) {
const temps = c.temps;
if (!temps || temps.length < 2) return '';
const good = temps.filter(t => t.good && t.raw != null);
if (good.length < 2) return '';
const tcTarget = c.tcReturnTcTarget;
const tcBand = c.tcReturnTcBand || 1;
const tll = c.tempLowLimit;
const cTag = c.sensitiveTrayTag;
const cKey = cTag ? (cTag.endsWith('.pv') ? cTag : cTag + '.pv') : '';
let cIdx = cKey ? temps.findIndex(t => t.tag === cKey) : -1;
if (cIdx < 0) cIdx = Math.round((temps.length - 1) * 0.7);
cIdx = Math.min(cIdx, temps.length - 1);
const vals = good.map(t => t.raw);
let lo = Math.min(...vals), hi = Math.max(...vals);
if (tcTarget != null) { lo = Math.min(lo, tcTarget - tcBand); hi = Math.max(hi, tcTarget + tcBand); }
if (tll != null) lo = Math.min(lo, tll);
const pad = (hi - lo) * 0.08 || 2; lo -= pad; hi += pad;
const range = hi - lo || 1;
const toY = v => Math.max(0, Math.min(100, (1 - (v - lo) / range) * 100));
const bandHtml = tcTarget != null
? `<div class="ff-thermo-band" style="top:${toY(tcTarget + tcBand)}%;height:${toY(tcTarget - tcBand) - toY(tcTarget + tcBand)}%"></div>`
: '';
const limitHtml = tll != null
? `<div class="ff-thermo-limit" style="top:${toY(tll)}%"></div>` : '';
const trayLabels = ['reb-A', 'B', 'C', 'D'];
const trayPcts = ['0%', '50%', '70%', '90%'];
const markers = temps.map((t, i) => {
if (!t.good || t.raw == null) return '';
const y = toY(t.raw);
const isC = i === cIdx;
const label = trayLabels[i] || 'T' + i;
const pct = trayPcts[i] || '';
const cState = isC && tcTarget != null
? (t.raw < tcTarget - tcBand ? 'low' : t.raw > tcTarget + tcBand ? 'high' : 'ok') : '';
const dotCls = isC
? 'ff-thermo-dot ff-thermo-c' + (cState ? ' ff-tc-' + cState : '')
: 'ff-thermo-dot';
return `<div class="ff-thermo-mark" style="top:${y}%"><span class="${dotCls}">${isC?'◉':'●'}</span><span class="ff-thermo-lbl">${fmtVal(t.raw)}<small> ${label}${pct?' '+pct:''}</small></span></div>`;
}).join('');
const cState = (cIdx >= 0 && temps[cIdx]?.good && temps[cIdx].raw != null && tcTarget != null)
? (temps[cIdx].raw < tcTarget - tcBand ? 'low' : temps[cIdx].raw > tcTarget + tcBand ? 'high' : 'ok')
: '';
const arrow = cState === 'low' ? '▼' : cState === 'high' ? '▲' : '';
const arrowCls = 'ff-thermo-arrow' + (cState ? ' ff-tc-' + cState : '');
// 옵션 푸터: Module 1 T_C 유지 SP 제안
const footer = (c.steamRecOp != null)
? `<div class="ff-thermo-ft">conf ${esc(c.steamConfidence||'')} · SP→${fmtVal(c.steamRecOp)}${c.tcDeviation!=null?` <span class="ff-tc-${Math.abs(c.tcDeviation)>1?'low':Math.abs(c.tcDeviation)>0.1?'':'ok'}">ε${fmtVal(c.tcDeviation)}</span>`:''}</div>`
: '';
return `<div class="ff-thermo">
<div class="ff-thermo-hd">T_C (℃)</div>
<div class="ff-thermo-body"><div class="ff-thermo-track">
<div class="ff-thermo-tick" style="top:0%"><span>${fmtVal(hi)}</span></div>
<div class="ff-thermo-tick" style="top:25%"><span>${fmtVal(hi-(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:50%"><span>${fmtVal(lo+(hi-lo)*0.5)}</span></div>
<div class="ff-thermo-tick" style="top:75%"><span>${fmtVal(lo+(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:100%"><span>${fmtVal(lo)}</span></div>
${bandHtml}
${limitHtml}
${markers}
</div></div>
${arrow ? `<div class="${arrowCls}">${arrow}</div>` : ''}
${footer}
</div>`;
}
function ffCard(c) {
const rows = (c.streams || []).map(s => {
const lvlTag = s.levelTag || '';
@@ -337,32 +413,37 @@ function ffCard(c) {
oninput="ffRampTargets[${c.columnId}]=this.value" placeholder="목표">
<button class="btn sm danger" onclick="ffCardRampStart(${c.columnId}, ${c.feedFiltered})" title="목표까지 FEED SP를 램프율로 점진 상승/하강">램프 시작 ▶</button>
<span class="ff-rt-mode">${ffRampDryRun ? '[모의]' : '[실쓰기]'}</span></div>`;
const thermo = ffThermometer(c);
const writeBadge = c.autoWriteActive ? '<span class="ff-write-badge">자동 SP 쓰기</span>' : '';
const wgBlocked = c.writeGuardBlockedSp != null
? `<div class="ff-wg-blocked">쓰기 차단: ${esc(c.writeGuardReason)} (SP <b>${fmtVal(c.writeGuardBlockedSp)}</b>)</div>`
: '';
const hasThermo = !!thermo;
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
${writeBadge}
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${modeLine}
${rampLine}
${rampCtl}
${banner}
${wgBlocked}
<table class="ff-tbl"><thead><tr>
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
</tr></thead><tbody>${rows}</tbody></table>
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
${front}
${tpBadge}
${front2}
${comp}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
<div class="ff-col-card ${c.enabled?'':'ff-disabled'} ${hasThermo?'ff-has-thermo':''}">
<div class="ff-card-main">
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
${writeBadge}
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${modeLine}
${rampLine}
${rampCtl}
${banner}
${wgBlocked}
<table class="ff-tbl"><thead><tr>
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
</tr></thead><tbody>${rows}</tbody></table>
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
${front}
${tpBadge}
${front2}
${comp}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>
${thermo}
</div>`;
}

View File

@@ -1,4 +1,5 @@
using Hc900Crawler.Core.Application.Feedforward;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Control;
@@ -36,6 +37,9 @@ public sealed class ColumnState
// WP5 1단계: 2-point front baseline (C pivot 기준 상/하 차온의 느린 baseline)
public FirstOrderLag UpperFrontBase { get; } = new(); // ΔT(CD) 상부
public FirstOrderLag LowerFrontBase { get; } = new(); // ΔT(CB) 하부
// 작업플랜-민감단온도 모듈1: T_C FB 경량 트림 상태
public double TcFbDwellTimerSec { get; set; } // T_C FB dwell 타이머(20분 정착 후 작동)
public double TcFbIntegral { get; set; } // T_C FB 적분항(편차 누적, gain 적용 전)
// WO-6 전환류 상태기계
public ColumnMode Mode { get; set; } = ColumnMode.Normal;
public double ImbalanceTimerSec { get; set; }
@@ -55,6 +59,15 @@ public sealed class ColumnState
public sealed class FeedforwardEngine
{
private readonly SteamAdvisor _steam;
private readonly ILogger<FeedforwardEngine> _logger;
public FeedforwardEngine(SteamAdvisor steam, ILogger<FeedforwardEngine> logger)
{
_steam = steam;
_logger = logger;
}
public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
{
var ts = cfg.ScanSec;
@@ -145,13 +158,47 @@ public sealed class FeedforwardEngine
if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
var (mode, modeReason, feedRecSp) = ApplyRecovery(
cfg, pv, st, ts, ff, vLossMa, frontState, tp.State, transient, ref outs); // WO-6 전환류 복귀
// ── 작업플랜-민감단온도 모듈1: T_C 유지 Steam OP 제안 ─────────────
double? steamRecOp = null; string? steamConf = null; double? tcDev = null;
if (mode == ColumnMode.Normal && !transient && Num.IsFinite(cfg.TcReturnTcTarget))
{
var tcTemp = ResolveTcTemp(cfg, pv);
if (Num.IsFinite(tcTemp))
{
double feed = ff;
double product = TryStreamPv(pv, cfg.ProductKey ?? "P", out var pvP) ? pvP : 0.0;
var sa = _steam.Predict(feed, product, cfg.TcReturnTcTarget);
steamConf = sa.Confidence;
double error = tcTemp - cfg.TcReturnTcTarget;
tcDev = error;
const double deadband = 0.1, gain = -0.6, dwellSec = 1200.0;
if (Math.Abs(error) > deadband) st.TcFbDwellTimerSec += ts;
else { st.TcFbDwellTimerSec = 0; st.TcFbIntegral = 0; }
if (sa.RecOp is double baseOp)
{
if (st.TcFbDwellTimerSec >= dwellSec)
{
st.TcFbIntegral += error * ts / 3600.0;
st.TcFbIntegral = Math.Clamp(st.TcFbIntegral, -3.0, 3.0);
steamRecOp = Math.Round(Math.Clamp(baseOp + gain * error + 0.05 * st.TcFbIntegral, 0, 100), 1);
}
else steamRecOp = Math.Round(baseOp, 1);
}
}
}
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp,
TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef,
UpperFrontState = f2.upState, LowerFrontState = f2.loState,
UpperFrontMetric = f2.upMetric, LowerFrontMetric = f2.loMetric };
UpperFrontMetric = f2.upMetric, LowerFrontMetric = f2.loMetric,
SensitiveTrayTag = cfg.SensitiveTrayTag,
TcReturnTcTarget = double.IsNaN(cfg.TcReturnTcTarget) ? null : cfg.TcReturnTcTarget,
TcReturnTcBand = cfg.TcReturnTcBand,
TempLowLimit = double.IsNaN(cfg.TempLowLimit) || cfg.TempLowLimit <= -1e8 ? null : cfg.TempLowLimit,
SteamRecOp = steamRecOp, SteamConfidence = steamConf, TcDeviation = tcDev };
}
// ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ──
@@ -539,6 +586,17 @@ public sealed class FeedforwardEngine
return cfg.FeedRecoverySp;
}
private static double ResolveTcTemp(ColumnConfig cfg, PvSnapshot pv)
{
if (pv.Temps is null || cfg.SensitiveTrayTag is null) return double.NaN;
var key = cfg.SensitiveTrayTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase)
? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var t in pv.Temps)
if (t.Tag.Equals(key, StringComparison.OrdinalIgnoreCase) && t.Good && Num.IsFinite(t.Value))
return t.Value;
return double.NaN;
}
private static (double? rec, string note) ComputeStream(
StreamConfig s, double ff, double dF, double ts, StreamState stt)
{