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
276 lines
11 KiB
Markdown
276 lines
11 KiB
Markdown
# WO-4 (P-4 느린 바이어스 적응) — 완전코드 작업지시서
|
|
|
|
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
|
|
> **선행 완료 전제**: §0 + WO-1 + WO-2 + WO-3 머지 완료. `ColumnConfig.BiasMaWindowSec`, `AdvisoryResult.VLossMa`,
|
|
> `StreamAdvisory.KObsSuggest`, `MovingAverage`(ComputationBlocks)는 **이미 존재**(다시 만들지 말 것).
|
|
> **불변식**: advisory — 쓰기 0건. K_obs·V_loss는 **장기 MA "제안/추세"** 일 뿐 엔진 K(=config TargetCoeff)는 변경 안 함.
|
|
|
|
## 목적
|
|
계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 이동평균**으로 흡수.
|
|
- `V_loss`는 순간값 신뢰불가(§5.3·§14.3 B등급) → **장기 MA(VLossMa)** 로만 의미 → 대시보드 표시 + **WO-6 트리거 입력**.
|
|
- commanded 스트림별 **K_obs = PV/FEED_filtered 의 MA** → config K와 비교해 계절 드리프트 "제안".
|
|
- **정상상태에서만 누적**(transient·BAD 제외) → 과도 표본 오염 방지.
|
|
|
|
## 변경 파일 (총 4개)
|
|
1. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` MA 필드 + `ApplyBias` + Tick 배선
|
|
2. `src/Web/wwwroot/js/ff.js` — VLossMa·KObs 표시 (Controller는 §0에서 `vLossMa`/`kObsSuggest` 이미 노출 — **변경 없음**)
|
|
3. `src/Web/wwwroot/css/ff.css` — 바이어스 행 스타일
|
|
4. `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs` — **신규** 테스트
|
|
|
|
---
|
|
|
|
## STEP 1 — `FeedforwardEngine.cs`
|
|
|
|
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
|
|
|
|
### 1.1 `ColumnState`에 MA 상태 추가
|
|
|
|
> 전제: WO-3에서 `PrevSteamOp` / `ThetaEst` 등이 이미 추가됨.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public double PrevSteamOp { get; set; } = double.NaN;
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public double PrevSteamOp { get; set; } = double.NaN;
|
|
// WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
|
|
public MovingAverage? VLossMaBlock { get; set; }
|
|
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
|
|
public Dictionary<string, StreamState> Streams { get; } = new();
|
|
```
|
|
|
|
### 1.2 Tick 배선 — return 직전, θ 제안 다음
|
|
|
|
> 전제: WO-3 이후 return 영역은 아래와 같다.
|
|
|
|
**찾기**:
|
|
```csharp
|
|
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
|
|
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
|
|
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
|
transient, treason, ff, outs, vloss, yield, mbState)
|
|
{ Temps = temps };
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
|
|
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
|
|
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 };
|
|
```
|
|
|
|
### 1.3 `ApplyBias` 메서드 추가 (ApplyThetaSuggestion 바로 뒤)
|
|
|
|
> 전제: WO-3가 추가한 `ApplyThetaSuggestion`은 `.ToList();` + `}` 로 끝난다(아래 앵커는 그 마지막 2줄).
|
|
|
|
**찾기**:
|
|
```csharp
|
|
outs = outs.Select(a => a.Role == StreamRole.Commanded
|
|
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
|
|
: a).ToList();
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
outs = outs.Select(a => a.Role == StreamRole.Commanded
|
|
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
|
|
: a).ToList();
|
|
}
|
|
|
|
// ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ──────
|
|
private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss,
|
|
bool transient, ref List<StreamAdvisory> outs, out double? vLossMa)
|
|
{
|
|
int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec)));
|
|
vLossMa = null;
|
|
|
|
// V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적)
|
|
if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value))
|
|
{
|
|
st.VLossMaBlock ??= new MovingAverage(window);
|
|
vLossMa = st.VLossMaBlock.Push(vloss.Value);
|
|
}
|
|
else if (st.VLossMaBlock is not null)
|
|
{
|
|
vLossMa = st.VLossMaBlock.Value; // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성)
|
|
}
|
|
|
|
// commanded 스트림별 K_obs = PV/FF 의 MA → 제안
|
|
if (transient || ff <= 1e-6) return;
|
|
outs = outs.Select(a =>
|
|
{
|
|
if (a.Role != StreamRole.Commanded) return a;
|
|
if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a;
|
|
if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; }
|
|
double kObs = ma.Push(smp.Value / ff);
|
|
return a with { KObsSuggest = kObs };
|
|
}).ToList();
|
|
}
|
|
```
|
|
> **`MovingAverage`에 `Value` 프로퍼티가 없으면** 추가 필요. 확인: 현재 `MovingAverage`는 `Push`만 있고 `Value`가 없을 수 있다 → STEP 1.4 참조.
|
|
|
|
### 1.4 `MovingAverage.Value` 보강 (필요 시)
|
|
|
|
**파일**: `src/Infrastructure/Control/ComputationBlocks.cs`
|
|
|
|
**찾기**:
|
|
```csharp
|
|
public double Push(double x)
|
|
{
|
|
_buf.Enqueue(x); _sum += x;
|
|
while (_buf.Count > _window) _sum -= _buf.Dequeue();
|
|
return _sum / _buf.Count;
|
|
}
|
|
```
|
|
|
|
**바꾸기**:
|
|
```csharp
|
|
public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN;
|
|
public double Push(double x)
|
|
{
|
|
_buf.Enqueue(x); _sum += x;
|
|
while (_buf.Count > _window) _sum -= _buf.Dequeue();
|
|
return _sum / _buf.Count;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 2 — `ff.js` : VLossMa·KObs 표시
|
|
|
|
**파일**: `src/Web/wwwroot/js/ff.js`
|
|
|
|
### 2.1 mb 문자열에 VLossMa 추가
|
|
|
|
**찾기**:
|
|
```javascript
|
|
const mb = `물질수지: ${esc(c.massBalanceState)}` +
|
|
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
|
|
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
const mb = `물질수지: ${esc(c.massBalanceState)}` +
|
|
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
|
|
(c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
|
|
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
|
|
```
|
|
|
|
### 2.2 스트림 행에 KObs 제안 (신뢰 셀 title에 병기)
|
|
|
|
**찾기**:
|
|
```javascript
|
|
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span></td>
|
|
```
|
|
|
|
**바꾸기**:
|
|
```javascript
|
|
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}</td>
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 3 — `ff.css`
|
|
|
|
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
|
|
```css
|
|
/* WO-4 K_obs 제안 */
|
|
.ff-kobs{color:#9fd;opacity:.8}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 4 — 신규 테스트 `FeedforwardBiasTests.cs`
|
|
|
|
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs`
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using ExperionCrawler.Core.Application.Feedforward;
|
|
using ExperionCrawler.Infrastructure.Control;
|
|
using Xunit;
|
|
|
|
namespace ExperionCrawler.Tests;
|
|
|
|
public class FeedforwardBiasTests
|
|
{
|
|
private static ColumnConfig Cfg() => new()
|
|
{
|
|
Id = 1, Name = "C-BIAS", Enabled = true, FeedTag = "f", ProductKey = "P",
|
|
ScanSec = 2, BiasMaWindowSec = 20, // 10 샘플 창
|
|
Streams = new[]
|
|
{
|
|
new StreamConfig { Key = "P", FlowTag = "p", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 },
|
|
new StreamConfig { Key = "D", FlowTag = "d", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.02 },
|
|
new StreamConfig { Key = "B", FlowTag = "b", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 },
|
|
}
|
|
};
|
|
|
|
// FEED 100 고정, P=95 → K_obs ≈ 0.95, D/B는 물질수지 충족용
|
|
private static PvSnapshot Snap() => new(
|
|
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
|
|
new Dictionary<string, TagSample> {
|
|
["P"] = new("p", 95, true, DateTime.UtcNow),
|
|
["D"] = new("d", 2, true, DateTime.UtcNow),
|
|
["B"] = new("b", 3, true, DateTime.UtcNow),
|
|
});
|
|
|
|
[Fact]
|
|
public void KObs_and_VLossMa_accumulate_in_steady_state()
|
|
{
|
|
var engine = new FeedforwardEngine();
|
|
var st = new ColumnState();
|
|
AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
|
|
for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
|
|
|
|
var p = res.Streams.Find(s => s.Key == "P")!;
|
|
Assert.NotNull(p.KObsSuggest);
|
|
Assert.InRange(p.KObsSuggest!.Value, 0.94, 0.96); // 95/100
|
|
|
|
Assert.NotNull(res.VLossMa);
|
|
Assert.InRange(res.VLossMa!.Value, -0.5, 0.5); // 100-(95+2+3)=0
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## STEP 5 — 검증
|
|
|
|
```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"
|
|
grep -nE "cfg\.TargetCoeff\s*=|s\.TargetCoeff\s*=" src/Infrastructure/Control/*.cs || echo "config K 무변경 OK"
|
|
```
|
|
|
|
**기대**: 빌드 0/0 · 테스트 **15/15**(WO-3까지 14 + 신규 1) · JS OK · 쓰기 0건 · config K 무변경 OK.
|
|
|
|
---
|
|
|
|
## 감독자 Sign-off
|
|
| 항목 | 상태 | 서명 |
|
|
|:--|:--:|:--:|
|
|
| 정상상태에서만 MA 누적(과도 표본 배제) | ✅ | windpacer 2026-05-31 |
|
|
| K_obs = PV/FF MA, config K 무변경 | ✅ | windpacer 2026-05-31 |
|
|
| VLossMa 산출(WO-6 트리거 입력) | ✅ | windpacer 2026-05-31 |
|
|
| MovingAverage.Value 보강 | ✅ | windpacer 2026-05-31 |
|
|
| 빌드 0/0 · 테스트 15/15 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
|
|
|
|
## 주의(약한 LLM 함정)
|
|
1. **config K(TargetCoeff) 대입 금지** — `KObsSuggest`에만 쓴다(제안).
|
|
2. **과도 중 MA 갱신 금지** — `transient` 시 Push 안 함(직전 값만 표시).
|
|
3. **MovingAverage.Value** 없으면 STEP 1.4로 보강(빌드 에러 방지).
|
|
4. positional record 인자추가 금지 — `VLossMa`/`KObsSuggest`는 init 프로퍼티(§0 기존).
|
|
</content>
|