? temps, bool transient)
{
if (temps is null || temps.Count == 0) return (null, null);
if (transient) return ("정착 대기(프론트 판정 보류)", null);
// 프론트 지표: 민감트레이 PCT 우선, 없으면 (상-하) 차온(ΔT)
double metric = double.NaN;
bool strong = false; // 차온이면 공통모드 상쇄 → 강신호
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { metric = tp.Pct; break; }
}
if (double.IsNaN(metric) && temps.Count >= 2 && temps[0].Good && temps[^1].Good)
{
metric = DiffTemp.Delta(temps[0].Pct, temps[^1].Pct); // 상-하 차온
strong = true;
}
if (double.IsNaN(metric)) return (null, null);
// 밴드폭: 컬럼 구배의 일부(대략 0.3°C 기본). refTau는 느린 기준(30분).
st.FrontInd ??= new FrontPositionIndicator(bandwidth: 0.3);
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
return (state, trim);
}
```
> **Controller 변경 없음**: §0에서 `frontPositionState`/`frontTrimAdvice` 이미 노출.
---
## STEP 3 — `ff.js` : 프론트 배너
**파일**: `src/Web/wwwroot/js/ff.js`
### 3.1 프론트 배너 const (theta const 다음, return 직전)
> 전제: WO-3가 `const theta = ...` 를 추가했다.
**찾기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
`
: '';
return `
```
**바꾸기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
`
: '';
const front = c.frontPositionState
? `프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
`
: '';
return `
```
### 3.2 카드 본문에 ${front} 삽입
> 전제: WO-3에서 `${theta}` 가 이미 들어가 있다.
**찾기**:
```javascript
${temps}
${theta}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
```
**바꾸기**:
```javascript
${temps}
${theta}
${front}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
```
---
## STEP 4 — `ff.css`
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-5 프론트 위치 */
.ff-front{font-size:12px;color:var(--t2);margin-top:6px}
.ff-front-warn{color:#ffd24d}
.ff-front-warn b{color:#ffb300}
```
---
## STEP 5 — 신규 테스트 `FeedforwardFrontTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs`
```csharp
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardFrontTests
{
[Fact]
public void Front_stable_within_band()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
// 기준이 100 부근으로 수렴하도록 여러번 같은 값
for (int i = 0; i < 50; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, grade) = ind.Update(100.1, 2, 60, true);
Assert.Contains("정상", state);
Assert.Null(trim);
Assert.Equal(Confidence.B, grade);
}
[Fact]
public void Front_rise_triggers_reflux_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: false);
var (state, trim, grade) = ind.Update(105.0, 2, 60, false); // 기준 위로 급상승
Assert.Contains("상승", state);
Assert.Equal("환류↑ 권장", trim);
Assert.Equal(Confidence.C, grade); // 단일 생온도 → C
}
[Fact]
public void Front_fall_triggers_boilup_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, _) = ind.Update(95.0, 2, 60, true);
Assert.Contains("하강", state);
Assert.Contains("boilup", trim);
}
}
```
---
## STEP 6 — 검증
```bash
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
```
**기대**: 빌드 0/0 · 테스트 **18/18**(WO-4까지 15 + 신규 3) · JS OK · 쓰기 0건.
### 런타임(선택)
- `sensitive_tray_tag='ti-6111c'`, `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5` 설정.
- 카드에 "프론트: 정상(프론트 안정)" 또는 드리프트 시 "프론트: 프론트 상승(경비물 혼입 위험) → 환류↑ 권장".
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 밴드 내 「정상」, 상/하 드리프트 트림 분기 | ✅ | windpacer 2026-05-31 |
| 단일 생온도 C / 차온 B 등급 | ✅ | windpacer 2026-05-31 |
| 트림은 문구만(SP 미변경) | ✅ | windpacer 2026-05-31 |
| 과도 중 판정 보류 | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 18/18 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
## 주의(약한 LLM 함정)
1. **WO-2 선행 필수** — `temps`가 없으면 프론트 metric을 못 구한다.
2. **트림은 권장 문구** — 절대 SP/recommendedSp를 바꾸지 말 것.
3. `temps[^1]`은 C# 인덱스(마지막 원소). 컴파일러 8.0+ 지원(현 프로젝트 net8.0 OK).
4. positional record 인자추가 금지 — `FrontPositionState`/`FrontTrimAdvice`는 §0 init 프로퍼티.