Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)
WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
total reflux recovery, config form expansion
305 lines
12 KiB
Markdown
305 lines
12 KiB
Markdown
# WO-5 (P-3 Sweet-Spot / 프론트 위치 지표) — 완전코드 작업지시서
|
|
|
|
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
|
|
> **선행 완료 전제(필수)**: §0 + WO-1 + WO-2 + WO-3 + WO-4 머지 완료. **WO-2(PCT/차온)가 핵심 입력**.
|
|
> `AdvisoryResult.FrontPositionState/FrontTrimAdvice`(§0), `DiffTemp`(WO-2), `temps`(WO-2)는 **이미 존재**.
|
|
> **불변식**: advisory — 쓰기 0건. 프론트 트림은 **권장 문구만**(SP 미변경).
|
|
|
|
## 목적
|
|
spec §13.5의 2층 구조 중 **느린 조성 프론트 위치**를 온도 피드백으로 모니터. WO-2의 제품존 PCT(또는 차온)를
|
|
**프론트 위치 프록시**로 삼아, 느린 기준 대비 드리프트 시 **환류↑/boilup 트림을 권장**(advisory).
|
|
spec §13.2 함정②(제품존 신호 약함)·§14.3 C등급(단일 생온도면 신뢰 낮음)을 등급으로 반영.
|
|
|
|
> **공정 정석**(`knowledge/PGMEA_측류추출운전방식_주의점.md §3 1순위`): 감도트레이 온도가 프론트 위치의 최선 지표.
|
|
> 프론트 상승(경비물 혼입 위험) → 환류↑ 권장 / 프론트 하강 → boilup↑·환류↓ 권장.
|
|
|
|
## 변경 파일 (총 5개)
|
|
1. `src/Infrastructure/Control/FrontPositionIndicator.cs` — **신규** 블록
|
|
2. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` 필드 + `ApplyFront` + Tick 배선
|
|
3. `src/Web/wwwroot/js/ff.js` — 프론트 상태/트림 배너 (Controller는 §0에서 `frontPositionState`/`frontTrimAdvice` 이미 노출)
|
|
4. `src/Web/wwwroot/css/ff.css` — 배너 스타일
|
|
5. `tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs` — **신규** 테스트
|
|
|
|
---
|
|
|
|
## STEP 1 — 신규 파일 `FrontPositionIndicator.cs`
|
|
|
|
**신규 파일**: `src/Infrastructure/Control/FrontPositionIndicator.cs`
|
|
|
|
```csharp
|
|
using ExperionCrawler.Core.Application.Feedforward;
|
|
|
|
namespace ExperionCrawler.Infrastructure.Control;
|
|
|
|
/// <summary>
|
|
/// 제품존 PCT/ΔT 의 느린 기준 대비 드리프트 → sweet-spot 건전성 + 트림 방향 권장(advisory).
|
|
/// 기준 = 느린 EMA(refTauSec). |metric - baseline| > bandwidth 면 드리프트.
|
|
/// I/O 없음, 컬럼 루프 단일 소유.
|
|
/// </summary>
|
|
public sealed class FrontPositionIndicator
|
|
{
|
|
private readonly double _bandwidth;
|
|
private readonly FirstOrderLag _baseline = new();
|
|
|
|
public FrontPositionIndicator(double bandwidth) => _bandwidth = Math.Max(1e-9, bandwidth);
|
|
|
|
/// <param name="frontMetric">민감트레이 PCT 또는 제품존 차온</param>
|
|
/// <param name="strongSignal">차온/analyzer 기반이면 true(등급↑), 단일 생온도면 false(C)</param>
|
|
public (string state, string? trimAdvice, Confidence grade) Update(
|
|
double frontMetric, double tsSec, double refTauSec, bool strongSignal)
|
|
{
|
|
double bl = _baseline.Step(frontMetric, refTauSec, tsSec);
|
|
double dev = frontMetric - bl;
|
|
Confidence grade = strongSignal ? Confidence.B : Confidence.C;
|
|
|
|
if (Math.Abs(dev) <= _bandwidth)
|
|
return ("정상(프론트 안정)", null, grade);
|
|
if (dev > 0)
|
|
return ("프론트 상승(경비물 혼입 위험)", "환류↑ 권장", grade);
|
|
return ("프론트 하강", "boilup↑·환류↓ 권장", grade);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 2 — `FeedforwardEngine.cs`
|
|
|
|
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
|
|
|
|
### 2.1 `ColumnState`에 인디케이터 추가
|
|
|
|
> 전제: WO-4에서 `KObsMa` 등이 이미 추가됨.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public MovingAverage? VLossMaBlock { get; set; }
|
|
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public MovingAverage? VLossMaBlock { get; set; }
|
|
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
|
|
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
### 2.2 Tick 배선 — return 직전, 바이어스 다음
|
|
|
|
> 전제: WO-4 이후 return 영역은 아래와 같다.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
|
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
|
transient, treason, ff, outs, vloss, yield, mbState)
|
|
{ Temps = temps, VLossMa = vLossMa };
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
|
|
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
|
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 };
|
|
```
|
|
|
|
### 2.3 `ApplyFront` 메서드 추가 (ApplyBias 바로 뒤)
|
|
|
|
> 전제: WO-4가 추가한 `ApplyBias`는 `}).ToList();` + `}` 로 끝난다.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
double kObs = ma.Push(smp.Value / ff);
|
|
return a with { KObsSuggest = kObs };
|
|
}).ToList();
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
double kObs = ma.Push(smp.Value / ff);
|
|
return a with { KObsSuggest = kObs };
|
|
}).ToList();
|
|
}
|
|
|
|
// ── WO-5 P-3: 프론트 위치(sweet-spot) 지표 + 트림 권장(advisory) ──────────────
|
|
private static (string? state, string? trim) ApplyFront(ColumnConfig cfg, ColumnState st, double ts,
|
|
IReadOnlyList<TempPoint>? 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
|
|
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
|
|
: '';
|
|
return `
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
|
|
const theta = thetaSug.length
|
|
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
|
|
: '';
|
|
const front = c.frontPositionState
|
|
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
|
|
: '';
|
|
return `
|
|
```
|
|
|
|
### 3.2 카드 본문에 ${front} 삽입
|
|
|
|
> 전제: WO-3에서 `${theta}` 가 이미 들어가 있다.
|
|
|
|
**찾기**:
|
|
```javascript
|
|
${temps}
|
|
${theta}
|
|
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
${temps}
|
|
${theta}
|
|
${front}
|
|
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 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 프로퍼티.
|
|
</content>
|