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
193 lines
11 KiB
Markdown
193 lines
11 KiB
Markdown
# ExperionCrawler — Agent Instructions
|
|
|
|
## Build / Run / Test
|
|
|
|
| Action | Command | Working Dir |
|
|
|--------|---------|-------------|
|
|
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
|
|
| Run (dev) | `dotnet run` | `src/Web/` |
|
|
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
|
|
| Tests | `dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj` | repo root |
|
|
|
|
Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
src/
|
|
├── Core/ — Interfaces (IExperionServices.cs), Domain entities, DTOs, Application Services
|
|
├── Infrastructure/ — OpcUa/, Database/, Certificates/, Csv/, Mcp/
|
|
└── Web/ — Program.cs, Controllers/, wwwroot/ (SPA)
|
|
```
|
|
|
|
All controllers are in `src/Web/Controllers/ExperionControllers.cs` (single file). All interfaces are in `src/Core/Application/Interfaces/IExperionServices.cs` (single file).
|
|
|
|
## Database
|
|
|
|
**PostgreSQL** (NOT SQLite — README is stale). Connection strings in `src/Web/appsettings.json`. TimescaleDB extension may be enabled on `history_table` via DDL only; no app code changes needed.
|
|
|
|
### ⚠️ Critical — `duration_seconds` semantics
|
|
|
|
`event_history_table.duration_seconds` = **직전 상태의 지속 시간(초)**. 현재 이벤트의 지속 시간이 아니다.
|
|
|
|
- `DigitalEventDetectorService`가 이전 상태를 처음 관측한 시점부터 값이 바뀔 때까지의 경과 시간(wall-clock)을 기록
|
|
- 예: `prev=R-RUN, curr=R-TRIP, duration_seconds=1423` → **R-RUN 상태가 1423초간 유지되다가** R-TRIP으로 전환된 것. TRIP이 1423초간 지속된 게 아님
|
|
- MCP 툴(`query_events`, `active_alarms`) JSON 출력 시 `prev_state_duration_s` 필드명으로 변환하여 반환하므로 LLM이 필드명만 보고 의미를 알 수 있음
|
|
- LLM 프롬프트에 이벤트 데이터를 넘길 때는 `(직전상태유지={duration}s)` 형식으로 전달
|
|
|
|
### sub_area (세부 Area: P6-1/P6-2 등)
|
|
|
|
하나의 area(P6)는 2개 Column(증류탑)으로 나뉜다. 태그 단위 sub_area를 별도 저장한다.
|
|
|
|
- 저장: `tag_metadata`의 `attribute='sub_area'` (EAV). Single Source of Truth는 tag_metadata (OPC UA 아님).
|
|
- 값 형식: 단일 `"P6-1"` 또는 **공용 `"P6-1,P6-2"`**(여러 sub_area 공용 설비, 예: 진공펌프 vp-2127a/b).
|
|
- **매칭은 항상 토큰 비교**: `'P6-1' = ANY(string_to_array(value, ','))` — `value = 'P6-1'` 직접 비교 금지(공용 누락).
|
|
- `MetadataLoaderService`는 `attribute IN ('desc','area')`만 건드림 → sub_area는 절대 덮어쓰지 않음.
|
|
- `event_history_table`에는 저장 안 함 → 이벤트 조회 시 `tag_metadata`를 `split_part(tagname,'.',1)`로 JOIN.
|
|
- seed: `IExperionDbService.SeedSubAreaAsync(dryRun)` — area-scoped 번호 prefix + pid_equipment 공용(role ILIKE '%공용%') 검출. prefix 미적용 태그는 NULL(수동 분류). `POST /api/tags/sub-area/seed`.
|
|
- MCP: `find_tags(sub_area=...)` 또는 `active_alarms/query_events/...(area="P6-1")` — '-' 포함 시 server.py가 자동으로 sub_area 토큰 매칭.
|
|
|
|
### ⏰ Timezone — KST
|
|
|
|
DB는 UTC 저장, LLM에는 **KST(UTC+9) 변환**해서 전달.
|
|
|
|
- `server.py`의 `_kst_str()` 함수로 UTC ISO 문자열 → KST ISO 문자열 변환
|
|
- 모든 시스템 프롬프트에 "모든 시각은 KST"라고 명시
|
|
- MCP 서버 재기동 필요 시 `uv run server.py --http`
|
|
|
|
## Critical Convention — JSON camelCase
|
|
|
|
`PropertyNamingPolicy = null` in Program.cs means C# PascalCase becomes JSON keys. **Frontend expects camelCase**. Every controller `Ok(...)` response MUST use explicit anonymous objects with camelCase keys:
|
|
|
|
```csharp
|
|
// ✅ Correct
|
|
return Ok(new { id = x.Id, tagName = x.TagName });
|
|
|
|
// ❌ Broken — JS gets undefined
|
|
return Ok(new { x.Id, x.TagName });
|
|
return Ok(myDto); // typed object
|
|
```
|
|
|
|
See `CODING_CONVENTIONS.md` for full details and checklist.
|
|
|
|
## Background Services (HostedServices in Program.cs)
|
|
|
|
- `ExperionRealtimeService` — OPC UA subscription, 500ms batch flush to DB
|
|
- `ExperionHistoryService` — periodic snapshot (60s) from realtime_table → history_table
|
|
- `ExperionOpcServerService` — exposes realtime data as OPC UA server (port 4841)
|
|
- `McpServerHostedService` — Python MCP server bridge
|
|
- `ExperionFastService` — high-frequency data capture sessions
|
|
- `ExperionFastCleanupService` — expired session cleanup
|
|
|
|
All registered as Singleton + HostedService. RealtimeService and OpcServerService share autostart flag files (`realtime_autostart.json`, `opcserver_autostart.json`) in the working directory.
|
|
|
|
## Frontend
|
|
|
|
Vanilla JS SPA. `wwwroot/index.html` + `js/app.js` + `css/style.css`. No build step. Tab-based navigation; tabs do NOT auto-fire API calls on entry (performance fix).
|
|
|
|
## OPC UA SDK Gotchas
|
|
|
|
- SDK v1.5.378.134 — `Session.Create()` is `[Obsolete]`; use `DefaultSessionFactory.CreateAsync()`
|
|
- `Subscription.Create()` / `Delete()` / `ApplyChanges()` also deprecated → async variants preferred
|
|
- Certificate validation must be attached AFTER `OpcUaConfigProvider.GetConfigAsync()` returns the config
|
|
- TCP connect timeout: wrap `SelectEndpointAsync` with a 10s `CancellationTokenSource` (OS default is 127s)
|
|
|
|
## Deploy
|
|
|
|
`sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`.
|
|
|
|
---
|
|
|
|
## Frontend Architecture (refactored 2026-05-24)
|
|
|
|
### Directory layout
|
|
|
|
```
|
|
wwwroot/
|
|
├── index.html ← data-src shell (229 lines, -87%)
|
|
├── panes/ ← 16 pane HTML partials (lazy-loaded on tab click)
|
|
├── js/
|
|
│ ├── core.js ← esc/setGlobal/log/api/fmtTs/fmtVal/parseEnumPv
|
|
│ │ activateTab/paneInit + dt* date picker
|
|
│ ├── app.js ← placeholder (10 lines)
|
|
│ ├── cert.js conn.js … ← 15 tab-specific files
|
|
│ └── docs.js ← separately maintained
|
|
├── css/ ← style.css monolithic (Phase 4 pending)
|
|
└── lib/ ← unchanged
|
|
```
|
|
|
|
### Tab lifecycle
|
|
|
|
1. User clicks `.nav-item[data-tab="X"]`
|
|
2. `core.js:activateTab(X)` fires:
|
|
- If `pane-X` not loaded → `fetch(/panes/X.html)` → `innerHTML` inject
|
|
- Activate pane + nav highlight
|
|
- `paneInit[X]?.()` — each tab file registers its own init
|
|
|
|
### PaneInit registration
|
|
|
|
| Tab | Init function | File |
|
|
|-----|--------------|------|
|
|
| docs | `paneInit.docs = docsInit` | `docs.js` |
|
|
| opcsvr | `paneInit.opcsvr = srvLoad` | `opcsvr.js` |
|
|
| t2s | `paneInit.t2s = t2sInitMode` | `t2s.js` |
|
|
| fast | `paneInit.fast = function() { … }` | `fast.js` |
|
|
| pid | `paneInit.pid = async function() { … }` | `pid.js` |
|
|
| llmchat | `paneInit.llmchat = function() { … }` | `llmchat.js` |
|
|
| kbadmin | `paneInit.kbadmin = async function() { … }` | `kbadmin.js` |
|
|
| cert/conn/crawl/db/nm-dash/pb/hist/evt/write | init 불필요 | — |
|
|
|
|
### JS load order (index.html)
|
|
|
|
`core.js` → `app.js` → cert/conn/crawl/db/nm-dash/pb/hist/opcsvr/t2s/fast/pid/evt/llmchat/kbadmin/write → `docs.js`
|
|
|
|
### Phase 4 pending — CSS 분리
|
|
|
|
`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조.
|
|
|
|
---
|
|
|
|
## Anchored Summary — Phase II (Auto-Write, WriteGuard, Audit, Auth)
|
|
|
|
### Done
|
|
- WO-2 (PCT monitor), WO-3 (θ auto-tune), WO-4 (slow bias), WO-5 (front position indicator), WO-6 (total reflux recovery), WO-7 (config form expansion): all **built, tested (22/22), JS OK, sign-off: windpacer 2026-05-31**.
|
|
- **Phase II auto-write (23 files)**:
|
|
- `FfOperatorAction` entity (`src/Core/Domain/Entities/FfOperatorAction.cs`)
|
|
- `ff_operator_action` DDL + `DbSet` + `OnModelCreating` in `ExperionDbContext.cs`
|
|
- `IFeedforwardWriteGuard` + `FeedforwardWriteGuard` (SP bounds, grade C, transient, NaN checks)
|
|
- `IFeedforwardAuditService` + `FeedforwardAuditService` (raw ADO.NET insert/query)
|
|
- `FeedforwardSupervisor.AutoWriteAsync` — per-stream OPC UA write after Tick (rate-limited, guarded, logged)
|
|
- `FeedforwardConfigStore` — `advisory_only` no longer hardcoded; reads/writes DB; `sp_node_id` column added
|
|
- `FeedforwardController` — auth (X-Kb-Token) on config/delete/write/audit; `POST /api/ff/write/{id}/{key}` manual SP write; `GET /api/ff/audit` audit query; write results merged in `MapColumn`
|
|
- `Program.cs` — `IFeedforwardAuditService` (Scoped), `IFeedforwardWriteGuard` (Singleton) registered
|
|
- `ff.js` — `ffToken()` + X-Kb-Token header; auto-write badge; per-stream write result; `spNodeId` field in stream table; `advisoryOnly` checkbox in form
|
|
- `ff.css` — `.ff-write-badge`, `.ff-write`, `.ff-write-err`, `.ff-wg-blocked`
|
|
- **Build 0W/0E, test 22/22, JS OK. Sign-off: windpacer 2026-05-31.**
|
|
|
|
### Key Design Decisions
|
|
- `IFeedforwardWriteGuard` is **Singleton** (stateless pure check functions) — no per-request instance needed.
|
|
- `IFeedforwardAuditService` is **Scoped** (depends on `ExperionDbContext` which is Scoped). Supervisor resolves it from `IServiceScopeFactory` scope.
|
|
- SP writes go through `IExperionOpcWriteClient` (Scoped) — each call creates + destroys an OPC UA session (acceptable for low-frequency writes).
|
|
- `sp_node_id` is stored per-stream in `ff_stream_config`. If null, auto-write is skipped for that stream (no write).
|
|
- Rate-limit: minimum `ScanSec * 2` between writes to the same stream (avoids double-writes on rapid ticks).
|
|
- Auth: `X-Kb-Token` header validated via `IKbAuthService.ValidateAsync()` — same mechanism as RAG KB admin. Token stored in `sessionStorage` by `kbadmin.js`.
|
|
- `AdvisoryResult.AutoWriteActive` is set by the Supervisor after Tick (not by the Engine). Engine remains pure computaton.
|
|
- `WriteGuardBlockedSp` / `WriteGuardReason` on `AdvisoryResult` are informative only — set when streams exist but all are blocked.
|
|
|
|
### Relevant Files
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `src/Infrastructure/Control/FeedforwardWriteGuard.cs` | New — SP safety checks |
|
|
| `src/Infrastructure/Control/FeedforwardAuditService.cs` | New — operator action log |
|
|
| `src/Core/Domain/Entities/FfOperatorAction.cs` | New — audit log entity |
|
|
| `src/Infrastructure/Control/FeedforwardSupervisor.cs` | Modified — `AutoWriteAsync` + `GetLastWrite` + IConfiguration |
|
|
| `src/Infrastructure/Control/FeedforwardConfigStore.cs` | Modified — reads/writes `advisory_only` from DB, `sp_node_id` |
|
|
| `src/Web/Controllers/FeedforwardController.cs` | Modified — auth, write, audit endpoints; write results in MapColumn |
|
|
| `src/Web/Program.cs` | Modified — register audit + write guard |
|
|
| `src/Web/wwwroot/js/ff.js` | Modified — token, write status, spNodeId, advisoryOnly form |
|
|
| `src/Web/wwwroot/css/ff.css` | Modified — auto-write/blocked styles |
|
|
| `src/Infrastructure/Database/ExperionDbContext.cs` | Modified — FfOperatorAction DbSet + DDL + OnModelCreating |
|
|
|
|
### Next Steps
|
|
- Phase II complete. Consider Phase III (operator dashboard, write confirmation dialog, trend overlay) when ordered.
|