Compare commits
12 Commits
30a3286d35
...
feat/p0-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52defc6a62 | ||
|
|
cdf2719a5e | ||
|
|
333d5f2a7b | ||
|
|
005e0dc3dd | ||
|
|
1b6a717e76 | ||
|
|
9d4269a02c | ||
|
|
c3a5258bf2 | ||
|
|
b820e6c33a | ||
|
|
3506a67c28 | ||
|
|
7f67f0e54d | ||
|
|
e2b1b8f6e0 | ||
|
|
c64bf08aa5 |
@@ -534,6 +534,47 @@ document.getElementById('rpGen').onclick = async () => {
|
||||
|
||||
**신뢰 블록(운전원 검산):** 템플릿에 `feed_qv`(IN)·`out_total`(OUT)·`mass_balance_closure`(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영.
|
||||
|
||||
## 13. Cleaning/Drawdown 데이터품질 게이트 (2026-06-14 검증)
|
||||
|
||||
적산(.QV) 메트릭의 **결정적 정합성 조건**: 비정상 운전 분(分)을 제외해야 생산량·수율·폐합이 맞는다. 데이터로 검증한 3신호 마스크.
|
||||
|
||||
**비정상(제외) 분 = 아래 중 하나:**
|
||||
| 신호 | 조건 | 의미 |
|
||||
|---|---|---|
|
||||
| 진공 | `PICA-*.PV > 300` | vacuum 깨짐 = cleaning/비운전 (정상 50~113, 비정상 750+) |
|
||||
| 제품~0 | `제품 FICQ-*18.PV < 10` | 제품 안 만듦 = cleaning |
|
||||
| drawdown | `원료 FICQ-*01.PV < 10` | feed≈0인데 운전 = 인벤토리 인출/링크 이송 |
|
||||
|
||||
**컬럼별 진공태그(P6는 무접미사, 그 외 A 접미사):**
|
||||
`C-6111→PICA-6111`, `C-6211→PICA-6211`, `C-8111→PICA-8111A`, `C-9111→PICA-9111A`, `C-9211→PICA-9211A`, `C-10111→PICA-10111A`, `C-10211→PICA-10211A`.
|
||||
|
||||
**QV Δ 알고리즘 (리셋 3종 + 마스크 통합):**
|
||||
```
|
||||
prev=None
|
||||
for 각 분(시간순):
|
||||
if 비정상(위 3신호): prev=None; continue # 체인 끊기(cleaning/drawdown 리셋 흡수)
|
||||
if prev≠None and 0 ≤ v−prev < 50000: total += v−prev # 양증분만
|
||||
prev = v
|
||||
```
|
||||
이 한 규칙이 처리: ①999999 자동 wrap(하강 스킵, 손실≈0) ②cleaning 리셋(제외분) ③운전조건변경 리셋(리셋전 누적 보존, 하강 스킵).
|
||||
|
||||
**검증 (풀기간 2026-02-09~06-05 폐합):**
|
||||
| 컬럼 | 마스크前 | 3신호 마스크後 |
|
||||
|---|---|---|
|
||||
| C-6111 | 99.3 | **99.6** |
|
||||
| C-6211 | 216.8 | **100.0** |
|
||||
| C-8111 | 99.0 | **99.8** |
|
||||
| C-9111 | 133.2 | **99.9** |
|
||||
| C-9211 | 97.8 | **99.9** |
|
||||
| C-10211 | 121.9 | **99.1** |
|
||||
| C-10111 | 53~105 | 91.4 (※) |
|
||||
|
||||
→ **6/7 컬럼 99~100% 폐합.** ※C-10111 91.4%는 버그 아님 — **C-9111→C-10111 연결라인** 테스트 중 C-10111이 받은 물질을 홀드업 축적(제품 미생산)한 정상 비정상상태. KPI가 "축적 중"을 정확히 표시.
|
||||
|
||||
**중요 현장 사실 — C-9111↔C-10111 연결라인:** 품질이슈 대응으로 두 컬럼을 잇는 라인 추가·시운전 중. C-9111 drawdown(자체 feed≈0, 제품↑)일 때 그 인출물이 C-10111 feed로 유입(C-10111 `FICQ-10101.PV` 0→~1090, 진공 750→45 운전전환). 개별 컬럼 풀기간 수지가 안 닫히는 주된 원인이며, drawdown 마스크로 정상일만 남겨 해결. C-9111 계량 자체는 정상(정상일 99.2~99.8%).
|
||||
|
||||
**구현 메모:** `Report:Cleaning` config(컬럼별 진공태그 + VacMax=300, ProductMin=10, FeedMin=10). `QvDeltaAsync`가 분 단위 마스크 적용. PV 메트릭(efficiency/yield/residual)도 동일 마스크. 결과 메타에 제외분 수 표기.
|
||||
|
||||
## 11. 다음(P1 훅)
|
||||
- `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
|
||||
- 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기.
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
## 7. 단계 (제안)
|
||||
- **P0 (MVP)**: §4 슬라이스 — 메트릭 3종 + 엑셀폼 1개 + history 소스 + 1컬럼(C-6111). **상세 코딩설계 → [`작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md`](작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md)**
|
||||
- **P1**: fast_record 소스 + `dynamics` 메트릭(루프 stiction/헌팅, step-test 셀프서비스).
|
||||
- **P1**: 온라인 히스토리안(1초 링버퍼+연속집계) + 실시간 KPI 누적기/알람 + `dynamics` 메트릭. **상세 → [`작업플랜-셀프서비스-분석리포트-P1-온라인히스토리안-스펙.md`](작업플랜-셀프서비스-분석리포트-P1-온라인히스토리안-스펙.md)**
|
||||
- **P2**: 토큰 파서 일반화 + 템플릿 업로드 UI + Daily 스케줄 자동생성.
|
||||
- **P3**: 멀티컬럼/멀티플랜트(KB 매핑 확장) + Monthly/Yearly + 모델학습 데이터 적재 연계.
|
||||
|
||||
|
||||
137
docs/작업플랜-셀프서비스-분석리포트-P1-온라인히스토리안-스펙.md
Normal file
137
docs/작업플랜-셀프서비스-분석리포트-P1-온라인히스토리안-스펙.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# P1 스펙 — 온라인 히스토리안 + 실시간 KPI/알람
|
||||
|
||||
> 2026-06-14. 상위: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md) · 기반: [`작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md`](작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md).
|
||||
> **목표:** P0의 결정론 메트릭(마스크·적산·리셋)을 **온라인(실시간)** 으로 확장. 핵심 근거 = 메트릭 로직이 전부 **causal/incremental** 이라, *시각 T의 온라인 결과 = [시작~T] 배치 결과와 동일* → 로직 변경 없이 스트리밍 가능.
|
||||
|
||||
---
|
||||
|
||||
## 0. 4대 컴포넌트
|
||||
```
|
||||
gateway(1s poll) ─► Hc900RealtimeService
|
||||
├─ realtime_table (현재값 upsert, 기존)
|
||||
└─ history_1s (A. 1초 append — 큐레이션 태그, 링버퍼 보존 N일)
|
||||
└─(B. 연속집계)─► history_1min (장기·무손실 롤업)
|
||||
Hc900LiveKpiService (C. 온라인 누적기) ─► live_kpi ─► (D. 알람엔진) ─► kpi_alert
|
||||
메트릭엔진(P0): source = { history_1s | history_1min | history_table(60s) | fast_record }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A. 1초 링버퍼 (`history_1s`) — 디스크 상한 고정
|
||||
|
||||
상시 1초 누적은 디스크 무한증가 → **윈도 고정 링버퍼**로 상한을 못박는다(시스템을 1년 돌려도 디스크 동일).
|
||||
|
||||
```sql
|
||||
CREATE TABLE hc900.history_1s (
|
||||
tagname text NOT NULL, recorded_at timestamptz NOT NULL,
|
||||
value text, controller_id text
|
||||
);
|
||||
SELECT create_hypertable('hc900.history_1s','recorded_at', chunk_time_interval => INTERVAL '1 hour');
|
||||
SELECT add_compression_policy('hc900.history_1s', INTERVAL '6 hours');
|
||||
SELECT add_retention_policy('hc900.history_1s', INTERVAL '14 days'); -- ← 윈도 = 디스크 예산 손잡이
|
||||
CREATE INDEX ON hc900.history_1s (tagname, recorded_at DESC);
|
||||
```
|
||||
- 보존정책 = **청크 DROP**(행 DELETE 아님)이라 비용 ≈ 0. 14일 후 자동 증발.
|
||||
- **디스크 = 태그수 × 윈도(초) × 행크기**. 예: 200태그 × 14일 × 1초 ≈ 2.4억 행, 압축(10~20×) 후 수~십 GB.
|
||||
|
||||
**적재**: `Hc900RealtimeService`(또는 신규 `Hc900FastHistoryService`)가 매 폴(1s)마다 **큐레이션 태그셋만** 배치 append(기존 500행/배치 패턴 재사용). realtime_table upsert와 병행.
|
||||
|
||||
## B. 연속집계 (`history_1min`) — 버퍼 evict 전 롤업, 무손실
|
||||
|
||||
1초가 버퍼에서 밀려나기 *전에* 60초로 materialize → 장기 이력 보존.
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW hc900.history_1min WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket('1 min', recorded_at) bucket, tagname,
|
||||
last(value::float, recorded_at) value, last(controller_id, recorded_at) controller_id
|
||||
FROM hc900.history_1s GROUP BY 1,2;
|
||||
SELECT add_continuous_aggregate_policy('hc900.history_1min',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '10 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes');
|
||||
```
|
||||
- **불변식: 집계 lag < 보존 윈도** (3h ≪ 14d) → raw 삭제 시 이미 집계 완료. 손실 0.
|
||||
- **2계층**: `history_1s`(최근 14일, 온라인·동특성·정밀 cleaning) + `history_1min`/기존 `history_table`(장기, 일·월·연 롤업).
|
||||
|
||||
## C. 온라인 KPI 누적기 (`Hc900LiveKpiService`)
|
||||
|
||||
배치처럼 윈도 재계산 대신 **러닝 상태** 유지(부하↓, 실시간↑). 기존 `Hc900HistoryService` BackgroundService 패턴.
|
||||
|
||||
```csharp
|
||||
// 컬럼×태그별 러닝 상태 (P0 QvDeltaAsync의 스트리밍 버전)
|
||||
record QvState { double Accum; double? PrevValue; bool PrevClean; }
|
||||
|
||||
// 매 1s 샘플(또는 1min tick)마다:
|
||||
foreach (col)
|
||||
cleaning = vac>VacMax || product<ProductMin || feed<FeedMin; // P0와 동일 마스크
|
||||
foreach (tag in [feed, product, lights, heavies, steam])
|
||||
if (!cleaning && !st.PrevClean && st.PrevValue is double p && v>=p && v-p<5e4)
|
||||
st.Accum += v - p; // P0와 동일 양증분합산
|
||||
st.PrevValue = v; st.PrevClean = cleaning;
|
||||
closure = 100 * (prodAccum+lightsAccum+heaviesAccum) / feedAccum;
|
||||
upsert live_kpi(col, 'closure'|'production'|'yield'|'energy', value, window_start, state, updated_at);
|
||||
```
|
||||
```sql
|
||||
CREATE TABLE hc900.live_kpi (
|
||||
column_id text, kpi text, value double precision,
|
||||
window_start timestamptz, state text, -- normal|cleaning|drawdown|transfer
|
||||
excluded_min int, updated_at timestamptz,
|
||||
PRIMARY KEY (column_id, kpi, window_start));
|
||||
```
|
||||
- **윈도 정책**: 일(日) 리셋(KST 자정 window_start 갱신) — 기존 누적은 `history_1min`/배치로 보존. (또는 trailing-24h 롤링 옵션.)
|
||||
- **크래시 복구**: 기동 시 `history_1s` 버퍼에서 현재 윈도를 **리플레이**해 상태 재구성(causal이라 동일 결과).
|
||||
- **인과성 보장**: 상태기반 = 매 샘플 현재값만 사용 → 누적기 결과(T) ≡ 배치(시작~T). 수용기준 §아래에서 동치 검증.
|
||||
|
||||
## D. 실시간 알람 (`kpi_alert`)
|
||||
|
||||
| 규칙 | 조건 | 의미 |
|
||||
|---|---|---|
|
||||
| cleaning 진입 | 진공>300 또는 제품<10 지속 | 세정 시작 |
|
||||
| drawdown 진입 | feed<10 & 제품>0 | 인벤토리 인출/컬럼간 이송 |
|
||||
| 폐합 이탈 | \|closure−100\|>2% 지속 M분 (정상구간서) | **계량 드리프트/누설/미계량 이송** |
|
||||
| 생산 정지 | production Δ≈0인데 비-cleaning | 트립/이상 |
|
||||
→ 기존 `event_history_table`/알림 경로 재사용. (마스크 자체가 cleaning/drawdown 판정을 이미 제공.)
|
||||
|
||||
---
|
||||
|
||||
## 큐레이션 태그셋 (1초 버퍼 대상)
|
||||
컬럼당(7컬럼): 유량 `FICQ-*01/13/14/16/18`·`FIQ-*15` 의 **.PV + .QV**, 진공 `PICA-*`, 민감단 `TI-*C`, 하부 `TICA-*A`(.PV/.SP/.OP), 핵심 압력/레벨. ≈ 25~30/컬럼 × 7 ≈ **~200 태그**. (전체 900 아님 — 메트릭·진단 관련만.) config 목록.
|
||||
|
||||
## P0 연계 (변경 최소)
|
||||
- 메트릭엔진 `source`에 `history_1s`/`history_1min` 추가 — **마스크 QvDelta SQL은 테이블만 교체**(이미 source 파라미터화). cleaning config 동일.
|
||||
- 웹 summary에 **live 모드**(live_kpi 직독) + source 셀렉터.
|
||||
|
||||
## 리스크 / 단서
|
||||
- 윈도(14일)보다 오래된 건 1초로 없음 → 60초만. 1초가 필요한 분석(전환 포렌식)은 최근 구간 한정. 일·월 리포트는 60초로 충분.
|
||||
- **불변식 집계 lag < 보존 윈도** 반드시 유지(아니면 장기 손실).
|
||||
- 태그셋 규율(900 전부 금지). 윈도 = 디스크 예산.
|
||||
- 진행 중 캠페인/이송(C-9111↔C-10111) 시 일중 폐합 ≠100%는 정상(KPI가 transfer 상태로 표시).
|
||||
- `realtime_table`은 upsert(시계열 아님) → 버퍼/메트릭 소스는 `history_1s`.
|
||||
|
||||
## 단계
|
||||
- **P1a**: `history_1s` 버퍼 + 보존/압축 + 큐레이션 적재(`Hc900FastHistoryService`).
|
||||
- **P1b**: 연속집계 `history_1min`(장기 무손실).
|
||||
- **P1c**: `Hc900LiveKpiService` 누적기 + `live_kpi` + summary live 모드.
|
||||
- **P1d**: 알람엔진 + `kpi_alert`.
|
||||
- (병행) `dynamics` 메트릭(fast/1s 전용: FOPDT·stiction), 월/연 롤업, C-9111↔C-10111 시스템 통합수지.
|
||||
|
||||
## 수용 기준
|
||||
1. `history_1s`가 정확히 최근 14일치 1초 보유, **디스크 상한 고정**; 그 이전은 `history_1min`에 보존(손실 0).
|
||||
2. **동치 검증**: `live_kpi`의 당일 closure == 같은 윈도 배치 `summary` 값(causal 동치).
|
||||
3. 합성 cleaning/drawdown 주입 시 상태 전이 + 알람 발화.
|
||||
4. 누적기 강제 재기동 → 버퍼 리플레이로 상태 동일 복구.
|
||||
|
||||
## 전환 후 "죽은코드" 점검 (2026-06-17)
|
||||
P1 도입은 **순수 추가형**(2계층을 기존 60초 경로 옆에 붙임, +650/−5)이라 전환으로 버려진 코드가 없음을 확인. 빌드 경고 0건(CS0169/0414/8321 없음). 정리 후보 2건을 추적한 결과:
|
||||
|
||||
| 대상 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| `Hc900HistoryService` (60s → `history_table`) | **살아있음** | 메트릭 기본 소스 + 장기저장. UI 드롭다운 기본값 `history (60초)` |
|
||||
| `history_1s` / `Hc900FastHistoryService` | **살아있음** | 카드 스파크라인·dynamics·LiveKpi 기본 소스(JS 하드코딩 `source=history_1s`) |
|
||||
| `history_1min` / `history_1min_src` | **휴면이나 적재 중 — 제거 금지** | UI 미노출·기본 소스 아님이지만 **연속집계 정책이 스케줄대로 적재** → `history_1s` 14일 보존 DROP 전 1분 롤업하는 **유일한 장기보존 경로**. 지우면 큐레이션 태그 >14일 데이터 소실. `SERIES_SOURCES` 허용목록에 있어 `?source=history_1min_src` 직접 API도 유효 |
|
||||
| `scripts/sql/p1_historian.sql` | **중복 참조 SQL (코드 아님) — 보존 결정** | 자동 실행 참조처 0건. 두 서비스가 기동 시 동일 DDL 멱등 적용하므로 런타임 무의존. **삭제해도 런타임 무영향**이나, 신규 DB 부트스트랩/재해복구 시 일괄 적용 참조본으로 유용 → 의도적으로 남김 |
|
||||
|
||||
**결론**: 전환으로 생긴 제거 대상 죽은코드 **없음**. `history_1min`은 죽은 게 아니라 의도된 2계층 장기 tier. `p1_historian.sql`은 멱등 부트스트랩과 중복되지만 운영 참조용으로 유지.
|
||||
|
||||
> **DDL 단일 진실원**: 런타임 스키마는 서비스의 `EnsureSchema`가 멱등 적용한다. `p1_historian.sql`은 그 *사본*(수동/일괄 적용용)이므로, 스키마 변경 시 **서비스 코드와 이 SQL을 함께 갱신**할 것(드리프트 주의).
|
||||
|
||||
---
|
||||
*근거: P0 결정론 마스크/적산이 causal → 온라인 동치. TimescaleDB 하이퍼테이블/보존/연속집계(기존 history_table 동일 인프라), BackgroundService 패턴(Hc900RealtimeService/HistoryService). 관련 메모리: [[qv-cleaning-mask-and-column-links]], [[product-pivot-selfservice-reporting]].*
|
||||
52
scripts/sql/p1_historian.sql
Normal file
52
scripts/sql/p1_historian.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- P1a: 1초 링버퍼 히스토리안 (hc900.history_1s)
|
||||
-- 적용: psql "host=localhost dbname=iiot_platform user=postgres" -f scripts/sql/p1_historian.sql
|
||||
-- 서비스(Hc900FastHistoryService)가 기동 시 동일 DDL을 멱등 적용하므로 수동 실행은 선택.
|
||||
SET search_path TO hc900;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hc900.history_1s (
|
||||
tagname text NOT NULL,
|
||||
recorded_at timestamptz NOT NULL DEFAULT now(),
|
||||
value text,
|
||||
controller_id text
|
||||
);
|
||||
|
||||
-- 하이퍼테이블 (1시간 청크 — evict/압축 단위)
|
||||
SELECT create_hypertable('hc900.history_1s', 'recorded_at',
|
||||
chunk_time_interval => INTERVAL '1 hour', if_not_exists => TRUE);
|
||||
|
||||
-- 압축 (6시간 지난 청크) + 링버퍼 보존 (14일 — 청크 DROP, 비용≈0)
|
||||
ALTER TABLE hc900.history_1s SET (timescaledb.compress, timescaledb.compress_segmentby = 'tagname');
|
||||
SELECT add_compression_policy('hc900.history_1s', INTERVAL '6 hours', if_not_exists => TRUE);
|
||||
SELECT add_retention_policy('hc900.history_1s', INTERVAL '14 days', if_not_exists => TRUE);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_h1s_tag ON hc900.history_1s (tagname, recorded_at DESC);
|
||||
|
||||
-- P1b: 연속집계 history_1min — 1초 버퍼 evict 전 60초 롤업(장기 무손실, 2계층)
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS hc900.history_1min
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket('1 minute', recorded_at) AS bucket, tagname,
|
||||
last(value, recorded_at) AS value, last(controller_id, recorded_at) AS controller_id
|
||||
FROM hc900.history_1s GROUP BY bucket, tagname
|
||||
WITH NO DATA;
|
||||
|
||||
-- 집계 lag(≤3h) ≪ 보존 윈도(14일) → raw 삭제 전 materialize 보장
|
||||
SELECT add_continuous_aggregate_policy('hc900.history_1min',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '10 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE);
|
||||
|
||||
-- P1c: 메트릭엔진 드롭인 소스(bucket→recorded_at) + 온라인 KPI 누적 테이블
|
||||
CREATE OR REPLACE VIEW hc900.history_1min_src AS
|
||||
SELECT tagname, bucket AS recorded_at, value, controller_id FROM hc900.history_1min;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hc900.live_kpi (
|
||||
column_id text NOT NULL, kpi text NOT NULL, window_start date NOT NULL,
|
||||
value double precision, unit text, state text, excluded_min int, status text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (column_id, kpi, window_start));
|
||||
|
||||
-- P1d: 실시간 알람 (edge-trigger: 진입 active, 해제 resolved)
|
||||
CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
|
||||
column_id text NOT NULL, rule text NOT NULL, severity text, active boolean NOT NULL DEFAULT true,
|
||||
message text, value double precision,
|
||||
opened_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz,
|
||||
PRIMARY KEY (column_id, rule));
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Data;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
@@ -13,14 +16,101 @@ public class ReportController : ControllerBase
|
||||
private readonly ReportFillService _fill;
|
||||
private readonly IReportTemplateStore _store;
|
||||
private readonly ReportColumnMap _map;
|
||||
private readonly Hc900DbContext _db;
|
||||
|
||||
// 웹 대시보드 기본 메트릭 세트
|
||||
private static readonly string[] SUMMARY_METRICS =
|
||||
{ "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure", "control_residual" };
|
||||
|
||||
public ReportController(IReportMetricService metrics, ReportFillService fill,
|
||||
IReportTemplateStore store, ReportColumnMap map)
|
||||
{ _metrics = metrics; _fill = fill; _store = store; _map = map; }
|
||||
IReportTemplateStore store, ReportColumnMap map, Hc900DbContext db)
|
||||
{ _metrics = metrics; _fill = fill; _store = store; _map = map; _db = db; }
|
||||
|
||||
/// <summary>온라인 KPI(live_kpi) 직독 — 누적기가 history_1s에서 갱신한 당일 실시간 값.</summary>
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> Live(string? column = null, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// (column_id,kpi)별 최신 window_start만 — 과거 날짜 잔여 행으로 인한 stale 표시 방지
|
||||
cmd.CommandText = @"SELECT DISTINCT ON (column_id, kpi)
|
||||
column_id, kpi, value, unit, state, excluded_min, status, window_start, updated_at
|
||||
FROM hc900.live_kpi" + (column == null ? "" : " WHERE column_id=@col") +
|
||||
" ORDER BY column_id, kpi, window_start DESC";
|
||||
if (column != null) { var p = cmd.CreateParameter(); p.ParameterName = "@col"; p.Value = column; cmd.Parameters.Add(p); }
|
||||
var items = new List<object>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
items.Add(new {
|
||||
Column = rd.GetString(0), Kpi = rd.GetString(1),
|
||||
Value = rd.IsDBNull(2) ? (double?)null : rd.GetDouble(2),
|
||||
Unit = rd.IsDBNull(3) ? null : rd.GetString(3),
|
||||
State = rd.IsDBNull(4) ? null : rd.GetString(4),
|
||||
ExcludedMin = rd.IsDBNull(5) ? (int?)null : rd.GetInt32(5),
|
||||
Status = rd.IsDBNull(6) ? null : rd.GetString(6),
|
||||
WindowStart = rd.GetFieldValue<DateTime>(7).ToString("yyyy-MM-dd"),
|
||||
UpdatedAt = rd.GetFieldValue<DateTime>(8)
|
||||
});
|
||||
return Ok(new { Count = items.Count, Items = items });
|
||||
}
|
||||
|
||||
/// <summary>카드 스파크라인 — 컬럼별 민감단 온도(TC) 최근 트렌드(history_1s 다운샘플).</summary>
|
||||
[HttpGet("sparks")]
|
||||
public async Task<IActionResult> Sparks(int minutes = 60, int points = 30, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
int bsec = Math.Max(30, minutes * 60 / Math.Max(5, points));
|
||||
var items = new List<object>();
|
||||
foreach (var col in _map.Columns())
|
||||
{
|
||||
var tc = _map.TcTag(col);
|
||||
var pts = new List<double>();
|
||||
if (tc != null)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
// 순수 SQL 버킷(Timescale 함수 미사용 — search_path 무관)
|
||||
cmd.CommandText = @"
|
||||
SELECT floor(extract(epoch FROM recorded_at)/@bsec) AS b, avg(value::float) v
|
||||
FROM hc900.history_1s
|
||||
WHERE tagname=@tc AND recorded_at > now() - @win::interval AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||
GROUP BY b ORDER BY b";
|
||||
void P(string n, object v) { var p = cmd.CreateParameter(); p.ParameterName = n; p.Value = v; cmd.Parameters.Add(p); }
|
||||
P("@tc", tc); P("@win", $"{minutes} minutes"); P("@bsec", bsec);
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct)) if (!rd.IsDBNull(1)) pts.Add(rd.GetDouble(1));
|
||||
}
|
||||
items.Add(new { Column = col, Tag = tc, Points = pts });
|
||||
}
|
||||
return Ok(new { Minutes = minutes, Items = items });
|
||||
}
|
||||
|
||||
/// <summary>활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함.</summary>
|
||||
[HttpGet("alerts")]
|
||||
public async Task<IActionResult> Alerts(bool activeOnly = true, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _db.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"SELECT column_id, rule, severity, active, message, value, opened_at, updated_at, resolved_at
|
||||
FROM hc900.kpi_alert" + (activeOnly ? " WHERE active" : "") +
|
||||
" ORDER BY active DESC, opened_at DESC";
|
||||
var items = new List<object>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
items.Add(new {
|
||||
Column = rd.GetString(0), Rule = rd.GetString(1),
|
||||
Severity = rd.IsDBNull(2) ? null : rd.GetString(2),
|
||||
Active = rd.GetBoolean(3),
|
||||
Message = rd.IsDBNull(4) ? null : rd.GetString(4),
|
||||
Value = rd.IsDBNull(5) ? (double?)null : rd.GetDouble(5),
|
||||
OpenedAt = rd.GetFieldValue<DateTime>(6),
|
||||
UpdatedAt = rd.GetFieldValue<DateTime>(7),
|
||||
ResolvedAt = rd.IsDBNull(8) ? (DateTime?)null : rd.GetFieldValue<DateTime>(8)
|
||||
});
|
||||
return Ok(new { Count = items.Count, Items = items });
|
||||
}
|
||||
|
||||
/// <summary>설정된 컬럼 목록(웹 UI 셀렉트용).</summary>
|
||||
[HttpGet("columns")]
|
||||
|
||||
@@ -175,6 +175,10 @@ builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
||||
|
||||
// ── P0 셀프서비스 리포트 ──────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumnMap>();
|
||||
// P1a: 1초 링버퍼 히스토리안 (history_1s, 보존정책으로 디스크 상한 고정)
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900FastHistoryService>();
|
||||
// P1c: 온라인 KPI 누적기 (history_1s → live_kpi)
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900LiveKpiService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
|
||||
Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
|
||||
|
||||
@@ -87,8 +87,44 @@
|
||||
}
|
||||
},
|
||||
"Report": {
|
||||
"Transfer": {
|
||||
"C-9111": "C-10111",
|
||||
"C-10111": "C-9111"
|
||||
},
|
||||
"Pumps": {
|
||||
"C-6111": [ "P-6102", "P-6118" ],
|
||||
"C-6211": [ "P-6201", "P-6218" ]
|
||||
},
|
||||
"Historian": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 1,
|
||||
"RetentionDays": 14
|
||||
},
|
||||
"LiveKpi": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 15,
|
||||
"Source": "history_1s",
|
||||
"ClosureTolerancePct": 2.0,
|
||||
"AlertDebounceSec": 60
|
||||
},
|
||||
"Cleaning": {
|
||||
"VacMax": 300,
|
||||
"ProductMin": 10,
|
||||
"FeedMin": 10,
|
||||
"VacTag": {
|
||||
"C-6111": "PICA-6111", "C-6211": "PICA-6211", "C-8111": "PICA-8111A",
|
||||
"C-9111": "PICA-9111A", "C-9211": "PICA-9211A",
|
||||
"C-10111": "PICA-10111A", "C-10211": "PICA-10211A"
|
||||
}
|
||||
},
|
||||
"Closure": {
|
||||
"C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] }
|
||||
"C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] },
|
||||
"C-6211": { "Feed": "FICQ-6201", "Outputs": [ "FICQ-6218", "FICQ-6214", "FICQ-6216" ] },
|
||||
"C-8111": { "Feed": "FICQ-8101", "Outputs": [ "FICQ-8118", "FICQ-8114", "FICQ-8116" ] },
|
||||
"C-9111": { "Feed": "FICQ-9101", "Outputs": [ "FICQ-9118", "FICQ-9114", "FICQ-9116" ] },
|
||||
"C-9211": { "Feed": "FICQ-9201", "Outputs": [ "FICQ-9218", "FICQ-9214", "FICQ-9216" ] },
|
||||
"C-10111": { "Feed": "FICQ-10101", "Outputs": [ "FICQ-10118", "FICQ-10114A", "FICQ-10116" ] },
|
||||
"C-10211": { "Feed": "FICQ-10201", "Outputs": [ "FICQ-10218", "FICQ-10214", "FICQ-10216" ] }
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
|
||||
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
|
||||
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
|
||||
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260615"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +237,6 @@
|
||||
<script src="/js/trend.js?v=20260611"></script>
|
||||
<script src="/js/ff.js?v=20260604"></script>
|
||||
<script src="/js/steam.js?v=20260606"></script>
|
||||
<script src="/js/reports.js?v=20260612"></script>
|
||||
<script src="/js/reports.js?v=20260615d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,9 @@ paneInit['reports'] = function () {
|
||||
const tog = (sel, wrap) => { const s = $(sel), w = $(wrap); if (s && w) { s.onchange = () => w.style.display = s.value === 'fast_record' ? '' : 'none'; s.onchange(); } };
|
||||
tog('rvSource', 'rvSessWrap'); tog('rpSource', 'rpSessionWrap');
|
||||
|
||||
// ① 라이브 모니터 — 자동갱신 폴러
|
||||
rmStart();
|
||||
|
||||
// ── ① 웹에서 바로 보기 ──
|
||||
const col = $('rvCol'), date = $('rvDate'), go = $('rvGo'), out = $('rvOut');
|
||||
if (date && !date.value) date.value = yKst();
|
||||
@@ -118,3 +121,130 @@ function renderSummary(d) {
|
||||
html += `</table>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── ① 라이브 모니터 (상태 카드 그리드) ─────────────────────── */
|
||||
const RM_COLOR = { normal:'#2ea043', idle:'#8b949e', no_data:'#6e7681', error:'#e5534b', cleaning:'#3a6ea5', drawdown:'#d29922' };
|
||||
const RM_LABEL = { normal:'정상', idle:'미가동', no_data:'데이터없음', error:'오류', cleaning:'세정', drawdown:'인출' };
|
||||
|
||||
function rmStart() {
|
||||
const auto = document.getElementById('rmAuto');
|
||||
if (window._rmTimer) { clearInterval(window._rmTimer); window._rmTimer = null; }
|
||||
rmRefresh();
|
||||
window._rmTimer = setInterval(() => {
|
||||
if (!document.getElementById('pane-reports')?.classList.contains('active')) return;
|
||||
if (!auto || auto.checked) rmRefresh();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
async function rmRefresh() {
|
||||
const grid = document.getElementById('rmGrid'); if (!grid) return;
|
||||
try {
|
||||
const [live, alerts, sparks] = await Promise.all([
|
||||
fetch('/api/report/live').then(r => r.json()),
|
||||
fetch('/api/report/alerts').then(r => r.json()),
|
||||
fetch('/api/report/sparks?minutes=60&points=30').then(r => r.json()).catch(() => ({ Items: [] }))
|
||||
]);
|
||||
// group
|
||||
const byCol = {}, alByCol = {}, spByCol = {};
|
||||
for (const it of (live.Items || [])) (byCol[it.Column] = byCol[it.Column] || {})[it.Kpi] = it;
|
||||
for (const a of (alerts.Items || [])) (alByCol[a.Column] = alByCol[a.Column] || []).push(a);
|
||||
for (const s of (sparks.Items || [])) spByCol[s.Column] = s.Points || [];
|
||||
|
||||
// 알람 배너
|
||||
const banner = document.getElementById('rmAlerts');
|
||||
const al = alerts.Items || [];
|
||||
banner.innerHTML = al.length === 0 ? ''
|
||||
: `<div style="border:1px solid #d29922;border-radius:8px;padding:8px 12px;background:rgba(210,153,34,.08)">
|
||||
<b>알람 ${al.length}</b> ` +
|
||||
al.map(a => `<span style="margin-right:14px">${a.Severity === 'warning' ? '🟠' : '🟡'} <b>${a.Column}</b> · ${esc2(a.Message || '')}</span>`).join('') + `</div>`;
|
||||
|
||||
// 카드 그리드 (컬럼 정렬)
|
||||
const cols = Object.keys(byCol).sort();
|
||||
grid.innerHTML = cols.map(c => rmCard(c, byCol[c], alByCol[c] || [], spByCol[c] || [])).join('');
|
||||
cols.forEach(c => { const el = document.getElementById('rmcard-' + cssId(c)); if (el) el.onclick = () => rmExpand(c); });
|
||||
|
||||
document.getElementById('rmStatus').textContent = '⟳ 갱신 ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
document.getElementById('rmStatus').textContent = '⚠ ' + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
function rmSpark(pts, color) {
|
||||
if (!pts || pts.length < 2) return '<div style="height:34px"></div>';
|
||||
const w = 196, h = 28, mn = Math.min(...pts), mx = Math.max(...pts), rng = (mx - mn) || 1;
|
||||
const dx = w / (pts.length - 1);
|
||||
const d = pts.map((v, i) => `${i ? 'L' : 'M'}${(i * dx).toFixed(1)},${(h - ((v - mn) / rng) * h).toFixed(1)}`).join(' ');
|
||||
const last = pts[pts.length - 1];
|
||||
return `<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="display:block;margin-top:6px;height:28px">
|
||||
<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
|
||||
<div style="font-size:10px;color:var(--t2);display:flex;justify-content:space-between">
|
||||
<span>민감단 ${mn.toFixed(1)}~${mx.toFixed(1)}℃</span><span>현재 ${last.toFixed(1)}</span></div>`;
|
||||
}
|
||||
|
||||
function rmCard(col, k, alerts, spark) {
|
||||
const st = (k.mass_balance_closure || k.production_total || {}).State || 'idle';
|
||||
// 알람 모드 우선 표시
|
||||
const modeAlert = alerts.find(a => a.Rule === 'cleaning' || a.Rule === 'drawdown');
|
||||
const cardState = modeAlert ? modeAlert.Rule : st;
|
||||
const color = RM_COLOR[cardState] || '#8b949e';
|
||||
const label = RM_LABEL[cardState] || cardState;
|
||||
|
||||
const clo = k.mass_balance_closure;
|
||||
const cv = clo && clo.Status === 'ok' && clo.Value != null ? clo.Value : null;
|
||||
const cloColor = cv == null ? '#8b949e' : (cv >= 98 && cv <= 102 ? '#2ea043' : '#e5534b');
|
||||
const num = (kpi, kind) => { const m = k[kpi]; return m && m.Status === 'ok' && m.Value != null ? rpFmt(m.Value, kind) : '—'; };
|
||||
|
||||
const chips = alerts.map(a => `<span style="font-size:11px;padding:1px 6px;border-radius:8px;background:${a.Severity==='warning'?'#e5534b':'#d29922'};color:#fff">${a.Rule}</span>`).join(' ');
|
||||
|
||||
return `<div id="rmcard-${cssId(col)}" style="border:1px solid ${color};border-radius:10px;padding:12px;cursor:pointer;background:rgba(255,255,255,.02)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>${col}</b>
|
||||
<span style="font-size:12px;color:${color}">● ${label}</span>
|
||||
</div>
|
||||
<div style="text-align:center;margin:8px 0">
|
||||
<span style="font-size:28px;font-weight:700;color:${cloColor}">${cv == null ? '—' : rpFmt(cv,'pct') + '%'}</span>
|
||||
<div style="font-size:11px;color:var(--t2)">물질수지 폐합</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--t2);display:grid;grid-template-columns:1fr 1fr;gap:2px 10px">
|
||||
<span>IN</span><span style="text-align:right;color:var(--t1,#ddd)">${num('feed_qv','kg')}</span>
|
||||
<span>OUT</span><span style="text-align:right;color:var(--t1,#ddd)">${num('out_total','kg')}</span>
|
||||
<span>생산</span><span style="text-align:right;color:var(--t1,#ddd)">${num('production_total','kg')} kg</span>
|
||||
<span>수율</span><span style="text-align:right;color:var(--t1,#ddd)">${num('yield_qv','ratio')}</span>
|
||||
</div>
|
||||
${rmSpark(spark, color)}
|
||||
${chips ? `<div style="margin-top:6px">${chips}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function rmExpand(col) {
|
||||
const box = document.getElementById('rmDetail');
|
||||
box.innerHTML = `<div class="mono" style="color:var(--t2)">⏳ ${col} 상세...</div>`;
|
||||
try {
|
||||
const today = new Date(Date.now() + 9*3600e3).toISOString().slice(0,10);
|
||||
const sum = await fetch(`/api/report/summary?column=${encodeURIComponent(col)}&date=${today}&source=history_1s`).then(r=>r.json());
|
||||
const dyn = await fetch('/api/report/metric', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({Column:col, Metric:'dynamics', PeriodDateKst:today, SourceTable:'history_1s'})}).then(r=>r.json());
|
||||
const clo = (sum.Metrics||[]).find(m=>m.Metric==='mass_balance_closure') || {};
|
||||
const e = clo.Extra || {};
|
||||
const res = (sum.Metrics||[]).find(m=>m.Metric==='control_residual') || {Extra:{}};
|
||||
box.innerHTML = `
|
||||
<div style="border:1px solid var(--bd,#444);border-radius:10px;padding:14px;margin-top:4px">
|
||||
<div style="display:flex;justify-content:space-between"><b>${col} 상세</b>
|
||||
<span class="mono" style="font-size:11px;color:var(--t2)">src=history_1s · ${today}</span></div>
|
||||
<div style="display:flex;gap:24px;flex-wrap:wrap;margin-top:8px;font-size:13px">
|
||||
<div><div style="color:var(--t2);font-size:11px">물질수지</div>
|
||||
IN ${rpFmt(e.feed_qv,'kg')} → OUT ${rpFmt(e.out_total,'kg')} kg<br>
|
||||
제품 ${rpFmt(e.out0_qv,'kg')} · 경비 ${rpFmt(e.out1_qv,'kg')} · 중비 ${rpFmt(e.out2_qv,'kg')}<br>
|
||||
폐합 <b>${clo.Status==='ok'?rpFmt(clo.Value,'pct')+'%':'N/A'}</b></div>
|
||||
<div><div style="color:var(--t2);font-size:11px">제어품질 (하부온도)</div>
|
||||
평균잔차 ${res.Status==='ok'?rpFmt(res.Value,'degC'):'N/A'}℃<br>
|
||||
sd ${rpFmt(res.Extra.sd,'degC')} · |잔차|>0.5℃ ${rpFmt(res.Extra.out_pct_0_5,'pct')}%</div>
|
||||
<div><div style="color:var(--t2);font-size:11px">동특성 (밸브, history_1s)</div>
|
||||
${dyn.Status==='ok'?`OP travel ${rpFmt(dyn.Value,'')} /h<br>헌팅주기 ${rpFmt(dyn.Extra.osc_period_s,'')}s · PV sd ${rpFmt(dyn.Extra.pv_sd,'degC')}`:`N/A (${esc2(dyn.Error||'')})`}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (e) { box.innerHTML = `<span class="mono" style="color:#e66">❌ ${e.message||e}</span>`; }
|
||||
}
|
||||
|
||||
function cssId(s) { return s.replace(/[^a-zA-Z0-9]/g, '_'); }
|
||||
function esc2(s) { return typeof esc === 'function' ? esc(s) : String(s).replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c])); }
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
<div class="report-pane" style="padding:20px;max-width:880px">
|
||||
<h2 style="margin-top:0">리포트 (P0)</h2>
|
||||
<div class="report-pane" style="padding:20px;max-width:1080px">
|
||||
<h2 style="margin-top:0">리포트</h2>
|
||||
|
||||
<!-- ── ① 웹에서 바로 보기 ──────────────────────────────── -->
|
||||
<!-- ── ① 라이브 모니터 (상태 카드 그리드) ──────────────── -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h3 style="margin:0 0 8px">① 웹에서 바로 보기</h3>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<h3 style="margin:0">① 라이브 모니터</h3>
|
||||
<span id="rmStatus" class="mono" style="font-size:12px;color:var(--t2)"></span>
|
||||
<label style="font-size:12px;color:var(--t2);margin-left:auto">
|
||||
<input type="checkbox" id="rmAuto" checked> 자동갱신 15s</label>
|
||||
</div>
|
||||
<div id="rmAlerts" style="margin-top:10px"></div>
|
||||
<div id="rmGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin-top:10px"></div>
|
||||
<div id="rmDetail" style="margin-top:12px"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── ② 웹에서 바로 보기 ──────────────────────────────── -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h3 style="margin:0 0 8px">② 웹에서 바로 보기 (기간 선택)</h3>
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
|
||||
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
|
||||
@@ -21,7 +34,7 @@
|
||||
|
||||
<!-- ── ② 엑셀 폼으로 내보내기 ──────────────────────────── -->
|
||||
<section style="border-top:1px solid var(--bd,#333);padding-top:18px">
|
||||
<h3 style="margin:0 0 8px">② 엑셀 폼으로 내보내기</h3>
|
||||
<h3 style="margin:0 0 8px">③ 엑셀 폼으로 내보내기</h3>
|
||||
<p style="color:var(--t2);font-size:13px;margin-top:0">
|
||||
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
|
||||
</p>
|
||||
|
||||
113
src/Infrastructure/Hc900/Hc900FastHistoryService.cs
Normal file
113
src/Infrastructure/Hc900/Hc900FastHistoryService.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Data;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Hc900;
|
||||
|
||||
/// <summary>
|
||||
/// P1a 1초 링버퍼 히스토리안. 매 N초(기본 1s) realtime_table의 큐레이션 태그를 history_1s에 append.
|
||||
/// Timescale 보존정책이 윈도(기본 14일)로 디스크 상한 고정. 60초 Hc900HistoryService 패턴.
|
||||
/// </summary>
|
||||
public class Hc900FastHistoryService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<Hc900FastHistoryService> _logger;
|
||||
private readonly Hc900RealtimeService _realtime;
|
||||
private readonly ReportColumnMap _map;
|
||||
private readonly bool _enabled;
|
||||
private readonly int _intervalSec;
|
||||
private readonly int _retentionDays;
|
||||
private string[] _tags = Array.Empty<string>();
|
||||
|
||||
public Hc900FastHistoryService(IServiceScopeFactory scopeFactory, ILogger<Hc900FastHistoryService> logger,
|
||||
Hc900RealtimeService realtime, ReportColumnMap map, IConfiguration config)
|
||||
{
|
||||
_scopeFactory = scopeFactory; _logger = logger; _realtime = realtime; _map = map;
|
||||
_enabled = config.GetValue("Report:Historian:Enabled", true);
|
||||
_intervalSec = Math.Max(1, config.GetValue("Report:Historian:IntervalSeconds", 1));
|
||||
_retentionDays = Math.Max(1, config.GetValue("Report:Historian:RetentionDays", 14));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_enabled) { _logger.LogInformation("[FastHistory] 비활성(Report:Historian:Enabled=false)"); return; }
|
||||
|
||||
_tags = _map.HistorianTags().ToArray();
|
||||
_logger.LogInformation("[FastHistory] 시작 — 간격 {Int}s, 보존 {Ret}일, 큐레이션 태그 {N}개",
|
||||
_intervalSec, _retentionDays, _tags.Length);
|
||||
|
||||
try { await EnsureSchemaAsync(stoppingToken); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[FastHistory] history_1s 스키마 준비 실패 — 서비스 중단"); return; }
|
||||
|
||||
if (_tags.Length == 0) { _logger.LogWarning("[FastHistory] 큐레이션 태그 0개 — 중단"); return; }
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSec), stoppingToken);
|
||||
if (!_realtime.IsConnected) continue; // 미연결 시 스킵
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<Hc900DbContext>();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(stoppingToken);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO hc900.history_1s (tagname, recorded_at, value, controller_id)
|
||||
SELECT tagname, now(), livevalue, controller_id
|
||||
FROM hc900.realtime_table
|
||||
WHERE tagname = ANY(@tags)";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@tags"; p.Value = _tags; cmd.Parameters.Add(p);
|
||||
var n = await cmd.ExecuteNonQueryAsync(stoppingToken);
|
||||
_logger.LogTrace("[FastHistory] {Count}행 append", n);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[FastHistory] append 실패"); }
|
||||
}
|
||||
_logger.LogInformation("[FastHistory] 종료");
|
||||
}
|
||||
|
||||
/// <summary>history_1s 하이퍼테이블 + 압축 + 보존(링버퍼) 멱등 생성.</summary>
|
||||
private async Task EnsureSchemaAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<Hc900DbContext>();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
var stmts = new[]
|
||||
{
|
||||
@"CREATE TABLE IF NOT EXISTS hc900.history_1s (tagname text NOT NULL,
|
||||
recorded_at timestamptz NOT NULL DEFAULT now(), value text, controller_id text)",
|
||||
"SELECT create_hypertable('hc900.history_1s','recorded_at', chunk_time_interval => INTERVAL '1 hour', if_not_exists => TRUE)",
|
||||
"ALTER TABLE hc900.history_1s SET (timescaledb.compress, timescaledb.compress_segmentby = 'tagname')",
|
||||
"SELECT add_compression_policy('hc900.history_1s', INTERVAL '6 hours', if_not_exists => TRUE)",
|
||||
$"SELECT add_retention_policy('hc900.history_1s', INTERVAL '{_retentionDays} days', if_not_exists => TRUE)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_h1s_tag ON hc900.history_1s (tagname, recorded_at DESC)",
|
||||
// P1b: 연속집계 — 1초 버퍼 evict 전 60초 롤업(장기 무손실)
|
||||
@"CREATE MATERIALIZED VIEW IF NOT EXISTS hc900.history_1min
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT time_bucket('1 minute', recorded_at) AS bucket, tagname,
|
||||
last(value, recorded_at) AS value, last(controller_id, recorded_at) AS controller_id
|
||||
FROM hc900.history_1s GROUP BY bucket, tagname WITH NO DATA",
|
||||
@"SELECT add_continuous_aggregate_policy('hc900.history_1min',
|
||||
start_offset => INTERVAL '3 hours', end_offset => INTERVAL '10 minutes',
|
||||
schedule_interval => INTERVAL '5 minutes', if_not_exists => TRUE)",
|
||||
// 메트릭엔진 드롭인 소스(bucket→recorded_at)
|
||||
@"CREATE OR REPLACE VIEW hc900.history_1min_src AS
|
||||
SELECT tagname, bucket AS recorded_at, value, controller_id FROM hc900.history_1min",
|
||||
};
|
||||
foreach (var s in stmts)
|
||||
{
|
||||
try { await using var cmd = conn.CreateCommand(); cmd.CommandText = s; await cmd.ExecuteNonQueryAsync(ct); }
|
||||
catch (Exception ex) { _logger.LogDebug("[FastHistory] DDL 스킵: {Msg}", ex.Message); } // 이미 적용된 ALTER 등
|
||||
}
|
||||
_logger.LogInformation("[FastHistory] history_1s 준비 완료(보존 {Ret}일)", _retentionDays);
|
||||
}
|
||||
}
|
||||
240
src/Infrastructure/Hc900/Hc900LiveKpiService.cs
Normal file
240
src/Infrastructure/Hc900/Hc900LiveKpiService.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using System.Data;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Hc900Crawler.Infrastructure.Reporting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Hc900;
|
||||
|
||||
/// <summary>
|
||||
/// P1c 온라인 KPI 누적기. 매 N초 오늘(KST)의 컬럼별 KPI를 history_1s에서 재계산해 live_kpi에 upsert.
|
||||
/// 재계산 방식 = 러닝 상태의 stateless 동등판: 결과(T) ≡ 배치(당일 시작~now), causal 보장(상태 복구 불필요).
|
||||
/// </summary>
|
||||
public class Hc900LiveKpiService : BackgroundService
|
||||
{
|
||||
private static readonly string[] KPIS = { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<Hc900LiveKpiService> _logger;
|
||||
private readonly Hc900RealtimeService _realtime;
|
||||
private readonly ReportColumnMap _map;
|
||||
private readonly bool _enabled;
|
||||
private readonly int _intervalSec;
|
||||
private readonly string _source;
|
||||
private readonly double _closureTol;
|
||||
private readonly int _debounceSec;
|
||||
private readonly Dictionary<string, DateTime> _cand = new(); // 알람 후보 시작시각(인메모리 디바운스)
|
||||
|
||||
public Hc900LiveKpiService(IServiceScopeFactory scopeFactory, ILogger<Hc900LiveKpiService> logger,
|
||||
Hc900RealtimeService realtime, ReportColumnMap map, IConfiguration config)
|
||||
{
|
||||
_scopeFactory = scopeFactory; _logger = logger; _realtime = realtime; _map = map;
|
||||
_enabled = config.GetValue("Report:LiveKpi:Enabled", true);
|
||||
_intervalSec = Math.Max(5, config.GetValue("Report:LiveKpi:IntervalSeconds", 15));
|
||||
_source = config.GetValue("Report:LiveKpi:Source", "history_1s")!; // 1초 버퍼 기본
|
||||
_closureTol = config.GetValue("Report:LiveKpi:ClosureTolerancePct", 2.0);
|
||||
_debounceSec = Math.Max(0, config.GetValue("Report:LiveKpi:AlertDebounceSec", 60));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_enabled) { _logger.LogInformation("[LiveKpi] 비활성"); return; }
|
||||
try { await EnsureSchemaAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "[LiveKpi] live_kpi 준비 실패"); return; }
|
||||
_logger.LogInformation("[LiveKpi] 시작 — 간격 {Int}s, 소스 {Src}", _intervalSec, _source);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(_intervalSec), stoppingToken);
|
||||
if (!_realtime.IsConnected) continue;
|
||||
|
||||
var todayKst = DateTime.UtcNow.AddHours(9).Date;
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var metrics = scope.ServiceProvider.GetRequiredService<IReportMetricService>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<Hc900DbContext>();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(stoppingToken);
|
||||
|
||||
int written = 0;
|
||||
foreach (var col in _map.Columns())
|
||||
{
|
||||
var results = new List<MetricResultDto>();
|
||||
foreach (var kpi in KPIS)
|
||||
results.Add(await metrics.ComputeAsync(new MetricRequestDto
|
||||
{ Column = col, Metric = kpi, PeriodDateKst = todayKst, SourceTable = _source }, stoppingToken));
|
||||
|
||||
// ★카드 state 단일 진실원 = 운전모드(순간 feed/product/vacuum). 누적생산이 아님.
|
||||
var (state, feedNow) = await DetectModeAsync(conn, col, stoppingToken);
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
int? excl = r.Extra.TryGetValue("excluded_min", out var e) && e is double ed ? (int)ed : null;
|
||||
await UpsertAsync(conn, col, r.Metric, todayKst, r.Value, r.Unit, state, excl, r.Status, stoppingToken);
|
||||
written++;
|
||||
}
|
||||
|
||||
// 카드 표시용 폐합 성분(IN/OUT) 저장
|
||||
var closure = results.First(r => r.Metric == "mass_balance_closure");
|
||||
if (closure.Status == "ok")
|
||||
{
|
||||
if (closure.Extra.TryGetValue("feed_qv", out var fq) && fq is double fqv)
|
||||
await UpsertAsync(conn, col, "feed_qv", todayKst, fqv, "kg", state, null, "ok", stoppingToken);
|
||||
if (closure.Extra.TryGetValue("out_total", out var ot) && ot is double otv)
|
||||
await UpsertAsync(conn, col, "out_total", todayKst, otv, "kg", state, null, "ok", stoppingToken);
|
||||
}
|
||||
|
||||
// P1d: 실시간 알람 — 같은 모드 기반(state와 진실원 일치)
|
||||
await EvaluateAlertsAsync(conn, col, state, feedNow, closure, stoppingToken);
|
||||
}
|
||||
_logger.LogDebug("[LiveKpi] {N}개 KPI 갱신 @ {Day}", written, todayKst);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[LiveKpi] 갱신 실패"); }
|
||||
}
|
||||
_logger.LogInformation("[LiveKpi] 종료");
|
||||
}
|
||||
|
||||
private static async Task UpsertAsync(System.Data.Common.DbConnection conn, string col, string kpi,
|
||||
DateTime ws, double? val, string? unit, string state, int? excl, string status, CancellationToken ct)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
INSERT INTO hc900.live_kpi (column_id, kpi, window_start, value, unit, state, excluded_min, status, updated_at)
|
||||
VALUES (@col,@kpi,@ws,@val,@unit,@state,@excl,@status, now())
|
||||
ON CONFLICT (column_id, kpi, window_start) DO UPDATE
|
||||
SET value=EXCLUDED.value, unit=EXCLUDED.unit, state=EXCLUDED.state,
|
||||
excluded_min=EXCLUDED.excluded_min, status=EXCLUDED.status, updated_at=now()";
|
||||
void P(string n, object? v) { var p = cmd.CreateParameter(); p.ParameterName = n; p.Value = v ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||
P("@col", col); P("@kpi", kpi); P("@ws", ws.Date); P("@val", val);
|
||||
P("@unit", unit); P("@state", state); P("@excl", excl); P("@status", status);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private async Task EnsureSchemaAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var conn = scope.ServiceProvider.GetRequiredService<Hc900DbContext>().Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
CREATE TABLE IF NOT EXISTS hc900.live_kpi (
|
||||
column_id text NOT NULL, kpi text NOT NULL, window_start date NOT NULL,
|
||||
value double precision, unit text, state text, excluded_min int, status text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (column_id, kpi, window_start));
|
||||
CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
|
||||
column_id text NOT NULL, rule text NOT NULL, severity text, active boolean NOT NULL DEFAULT true,
|
||||
message text, value double precision,
|
||||
opened_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz,
|
||||
PRIMARY KEY (column_id, rule));";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>★운전모드 단일 진실원: 최신 feed/product/vacuum 샘플로 idle/cleaning/drawdown/normal 분류. 카드 state·알람 공통.</summary>
|
||||
private async Task<(string mode, double? feed)> DetectModeAsync(System.Data.Common.DbConnection conn, string col, CancellationToken ct)
|
||||
{
|
||||
if (!_map.TryResolveCleaning(col, out var cl) || cl is null) return ("no_data", null);
|
||||
double? vac = null, prod = null, feed = null;
|
||||
await using (var q = conn.CreateCommand())
|
||||
{
|
||||
q.CommandText = $@"SELECT
|
||||
(SELECT value::float FROM hc900.{_source} WHERE tagname=@vac AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1),
|
||||
(SELECT value::float FROM hc900.{_source} WHERE tagname=@prod AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1),
|
||||
(SELECT value::float FROM hc900.{_source} WHERE tagname=@feed AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1)";
|
||||
void P(string n, object? v) { var p = q.CreateParameter(); p.ParameterName = n; p.Value = v ?? DBNull.Value; q.Parameters.Add(p); }
|
||||
P("@vac", cl.VacTag ?? ""); P("@prod", cl.ProductTag); P("@feed", cl.FeedTag);
|
||||
await using var rd = await q.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct))
|
||||
{
|
||||
vac = rd.IsDBNull(0) ? null : rd.GetDouble(0);
|
||||
prod = rd.IsDBNull(1) ? null : rd.GetDouble(1);
|
||||
feed = rd.IsDBNull(2) ? null : rd.GetDouble(2);
|
||||
}
|
||||
}
|
||||
// 펌프 가동 여부(realtime_table 현재 상태) — 정지면 잔류 흐름과 무관하게 idle.
|
||||
var pumps = _map.PumpTags(col);
|
||||
bool pumpKnown = pumps.Count > 0, pumpRunning = false;
|
||||
if (pumpKnown)
|
||||
{
|
||||
await using var pq = conn.CreateCommand();
|
||||
pq.CommandText = "SELECT count(*) FROM hc900.realtime_table WHERE tagname = ANY(@p) AND livevalue IN ('1','5','L-RUN','R-RUN')";
|
||||
var pp = pq.CreateParameter(); pp.ParameterName = "@p"; pp.Value = pumps.ToArray(); pq.Parameters.Add(pp);
|
||||
pumpRunning = Convert.ToInt64(await pq.ExecuteScalarAsync(ct)) > 0;
|
||||
}
|
||||
|
||||
if (prod is null && feed is null && !pumpRunning) return ("no_data", feed);
|
||||
bool fLow = (feed ?? 0) < cl.FeedMin, pLow = (prod ?? 0) < cl.ProductMin, vHigh = vac.HasValue && vac > cl.VacMax;
|
||||
string mode =
|
||||
(pumpKnown && !pumpRunning) ? "idle" // ★펌프 정지 = 셧다운(흐름 잔류 무관)
|
||||
: (fLow && pLow) ? (pumpRunning ? "normal" : "idle") // 펌프 도는데 흐름↓ = 가동(저율/startup)
|
||||
: vHigh ? "cleaning"
|
||||
: (fLow) ? "drawdown"
|
||||
: (pLow) ? "cleaning"
|
||||
: "normal";
|
||||
return (mode, feed);
|
||||
}
|
||||
|
||||
/// <summary>P1d 알람: 운전모드(카드 state와 동일 진실원) + 폐합 이탈 → kpi_alert edge-trigger.</summary>
|
||||
private async Task EvaluateAlertsAsync(System.Data.Common.DbConnection conn, string col, string mode, double? feed,
|
||||
MetricResultDto closure, CancellationToken ct)
|
||||
{
|
||||
var desired = new List<(string rule, string sev, string msg, double? val)>();
|
||||
if (mode == "cleaning") desired.Add(("cleaning", "info", "진공 高 또는 제품~0 (세정/비운전)", null));
|
||||
else if (mode == "drawdown")
|
||||
{
|
||||
var partner = _map.TransferPartner(col);
|
||||
var msg = partner is null ? "feed≈0 인데 출력 — 인벤토리 인출"
|
||||
: $"feed≈0 인데 출력 — 인벤토리 인출 또는 {partner} 이송";
|
||||
desired.Add(("drawdown", "info", msg, feed));
|
||||
}
|
||||
else if (mode == "normal" && closure.Status == "ok" && closure.Value is double cv && Math.Abs(cv - 100) > _closureTol)
|
||||
desired.Add(("closure_deviation", "warning", $"폐합 {cv:F1}% (100±{_closureTol}% 이탈 — 계량 드리프트/누설/이송 점검)", cv));
|
||||
|
||||
// 디바운스: 조건이 _debounceSec 이상 연속 유지된 룰만 confirm(발화). flapping 방지.
|
||||
var now = DateTime.UtcNow;
|
||||
var rawRules = desired.Select(d => d.rule).ToHashSet();
|
||||
var confirmed = new List<(string rule, string sev, string msg, double? val)>();
|
||||
foreach (var d in desired)
|
||||
{
|
||||
var key = col + "|" + d.rule;
|
||||
if (!_cand.TryGetValue(key, out var since)) { since = now; _cand[key] = since; }
|
||||
if ((now - since).TotalSeconds >= _debounceSec) confirmed.Add(d);
|
||||
}
|
||||
foreach (var k in _cand.Keys.Where(k => k.StartsWith(col + "|") && !rawRules.Contains(k[(col.Length + 1)..])).ToList())
|
||||
_cand.Remove(k); // 더 이상 후보 아님 → 디바운스 리셋
|
||||
|
||||
await SyncAlertsAsync(conn, col, confirmed, ct);
|
||||
}
|
||||
|
||||
/// <summary>desired 룰은 active 유지/발화, 나머지 active 알람은 resolved 처리(edge-trigger).</summary>
|
||||
private static async Task SyncAlertsAsync(System.Data.Common.DbConnection conn, string col,
|
||||
List<(string rule, string sev, string msg, double? val)> desired, CancellationToken ct)
|
||||
{
|
||||
foreach (var (rule, sev, msg, val) in desired)
|
||||
{
|
||||
await using var up = conn.CreateCommand();
|
||||
up.CommandText = @"
|
||||
INSERT INTO hc900.kpi_alert (column_id, rule, severity, active, message, value, opened_at, updated_at, resolved_at)
|
||||
VALUES (@c,@r,@s,true,@m,@v,now(),now(),NULL)
|
||||
ON CONFLICT (column_id, rule) DO UPDATE SET
|
||||
severity=EXCLUDED.severity, active=true, message=EXCLUDED.message, value=EXCLUDED.value, updated_at=now(),
|
||||
opened_at = CASE WHEN hc900.kpi_alert.active THEN hc900.kpi_alert.opened_at ELSE now() END,
|
||||
resolved_at = NULL";
|
||||
void P(string n, object? v) { var p = up.CreateParameter(); p.ParameterName = n; p.Value = v ?? DBNull.Value; up.Parameters.Add(p); }
|
||||
P("@c", col); P("@r", rule); P("@s", sev); P("@m", msg); P("@v", val);
|
||||
await up.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
await using var res = conn.CreateCommand();
|
||||
res.CommandText = @"UPDATE hc900.kpi_alert SET active=false, resolved_at=now(), updated_at=now()
|
||||
WHERE column_id=@c AND active AND NOT (rule = ANY(@rules))";
|
||||
var pc = res.CreateParameter(); pc.ParameterName = "@c"; pc.Value = col; res.Parameters.Add(pc);
|
||||
var pr = res.CreateParameter(); pr.ParameterName = "@rules"; pr.Value = desired.Select(d => d.rule).ToArray(); res.Parameters.Add(pr);
|
||||
await res.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ public enum QvKind { Single, Ratio, Closure }
|
||||
/// <summary>Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사.</summary>
|
||||
public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IReadOnlyList<string> Outputs, double? Max = null);
|
||||
|
||||
/// <summary>비정상운전(cleaning/drawdown) 제외 마스크. 진공高 또는 제품~0 또는 feed~0인 분 제외.</summary>
|
||||
public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductTag, double ProductMin, string FeedTag, double FeedMin);
|
||||
|
||||
/// <summary>
|
||||
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
|
||||
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
|
||||
@@ -29,10 +32,70 @@ public sealed class ReportColumnMap
|
||||
public IReadOnlyList<string> Columns()
|
||||
=> _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 1초 히스토리안 큐레이션 태그셋 — 메트릭/마스크가 읽는 태그만 기존 config에서 유도(중복정의 없음).
|
||||
/// 컬럼당: 유량(feed/product/steam/lights/heavies) .PV+.QV, 진공 .PV, 민감단 TC .PV, 하부루프 .PV/.SP/.OP.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> HistorianTags()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
void addPvQv(string? baseOrTag)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOrTag)) return;
|
||||
var b = StripAttr(baseOrTag!);
|
||||
set.Add(b + ".PV"); set.Add(b + ".QV");
|
||||
}
|
||||
foreach (var col in Columns())
|
||||
{
|
||||
var sa = _config.GetSection($"SteamAdvisor:Columns:{col}");
|
||||
addPvQv(sa["Feed"]); addPvQv(sa["Product"]); addPvQv(sa["SteamFlow"]);
|
||||
if (Norm(sa["TC"]) is string tc) set.Add(tc); // 민감단 .PV
|
||||
if (!string.IsNullOrWhiteSpace(sa["SteamOp"])) // 하부루프 TICA-*A
|
||||
{ var lb = StripAttr(sa["SteamOp"]!); set.Add(lb + ".PV"); set.Add(lb + ".SP"); set.Add(lb + ".OP"); }
|
||||
|
||||
var vac = _config[$"Report:Cleaning:VacTag:{col}"]; // 진공
|
||||
if (!string.IsNullOrWhiteSpace(vac)) set.Add(vac!.Contains('.') ? vac! : vac + ".PV");
|
||||
|
||||
var cl = _config.GetSection($"Report:Closure:{col}"); // 폐합 스트림
|
||||
addPvQv(cl["Feed"]);
|
||||
foreach (var o in cl.GetSection("Outputs").Get<string[]>() ?? Array.Empty<string>()) addPvQv(o);
|
||||
}
|
||||
return set.OrderBy(x => x).ToList();
|
||||
}
|
||||
|
||||
/// <summary>해당 컬럼에 폐합(물질수지) 설정이 있는지.</summary>
|
||||
public bool HasClosure(string column)
|
||||
=> _config.GetSection($"Report:Closure:{column}").Exists();
|
||||
|
||||
/// <summary>민감단(품질) 온도 태그 — 스파크라인/트렌드용.</summary>
|
||||
public string? TcTag(string column) => Norm(_config[$"SteamAdvisor:Columns:{column}:TC"]);
|
||||
|
||||
/// <summary>컬럼간 이송 라인 상대 컬럼(있으면). 예: C-9111↔C-10111. 없으면 null(=순수 인벤토리 인출).</summary>
|
||||
public string? TransferPartner(string column) => _config[$"Report:Transfer:{column}"];
|
||||
|
||||
/// <summary>가동 판정용 펌프 상태태그(.PV). Report:Pumps:{col} 우선, 없으면 제품펌프 P-{제품번호} 유도.</summary>
|
||||
public IReadOnlyList<string> PumpTags(string column)
|
||||
{
|
||||
var configured = _config.GetSection($"Report:Pumps:{column}").Get<string[]>();
|
||||
if (configured is { Length: > 0 })
|
||||
return configured.Select(t => t.Contains('.') ? t : t + ".PV").ToList();
|
||||
var prod = _config[$"SteamAdvisor:Columns:{column}:Product"]; // 예: FICQ-6118.PV
|
||||
if (string.IsNullOrWhiteSpace(prod)) return Array.Empty<string>();
|
||||
var num = StripAttr(prod!).Split('-').Last(); // 6118
|
||||
return new[] { $"P-{num}.PV" }; // 제품펌프
|
||||
}
|
||||
|
||||
/// <summary>동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용.</summary>
|
||||
public bool TryResolveDynamics(string column, out string? pvTag, out string? opTag)
|
||||
{
|
||||
pvTag = opTag = null;
|
||||
var op = _config[$"SteamAdvisor:Columns:{column}:SteamOp"]; // 예: TICA-6111A.OP
|
||||
if (string.IsNullOrWhiteSpace(op)) return false;
|
||||
var b = StripAttr(op);
|
||||
pvTag = b + ".PV"; opTag = b + ".OP";
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryResolve(string column, string metric, out MetricSpec? spec)
|
||||
{
|
||||
spec = null;
|
||||
@@ -117,6 +180,25 @@ public sealed class ReportColumnMap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>cleaning/drawdown 마스크 해석. 제품·feed는 SteamAdvisor:Columns, 진공태그·임계는 Report:Cleaning.</summary>
|
||||
public bool TryResolveCleaning(string column, out CleaningSpec? spec)
|
||||
{
|
||||
spec = null;
|
||||
var sec = _config.GetSection($"SteamAdvisor:Columns:{column}");
|
||||
var product = Norm(sec["Product"]);
|
||||
var feed = Norm(sec["Feed"]);
|
||||
if (product is null || feed is null) return false;
|
||||
|
||||
var cl = _config.GetSection("Report:Cleaning");
|
||||
var vacBase = _config[$"Report:Cleaning:VacTag:{column}"];
|
||||
string? vacTag = string.IsNullOrWhiteSpace(vacBase) ? null : (vacBase!.Contains('.') ? vacBase : vacBase + ".PV");
|
||||
double vmax = double.TryParse(cl["VacMax"], out var a) ? a : 300;
|
||||
double pmin = double.TryParse(cl["ProductMin"], out var b) ? b : 10;
|
||||
double fmin = double.TryParse(cl["FeedMin"], out var d) ? d : 10;
|
||||
spec = new CleaningSpec(vacTag, vmax, product, pmin, feed, fmin);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>속성(.PV 등) 없으면 .PV 부여.</summary>
|
||||
private static string? Norm(string? tag)
|
||||
=> string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV");
|
||||
|
||||
@@ -26,31 +26,42 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
private static readonly HashSet<string> QV_METRICS =
|
||||
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
|
||||
|
||||
// recorded_at 윈도로 조회 가능한 시계열 소스(동일 long 포맷). history_1min_src=연속집계 호환뷰.
|
||||
private static readonly HashSet<string> SERIES_SOURCES =
|
||||
new() { "history_table", "history_1s", "history_1min_src" };
|
||||
|
||||
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||
{
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
// history_table(60s) | history_1s(1s 버퍼) | history_1min_src(연속집계) | fast_record. 미지정/미허용→history_table.
|
||||
string tbl = isFast ? "fast_record"
|
||||
: SERIES_SOURCES.Contains(req.SourceTable) ? req.SourceTable : "history_table";
|
||||
var res = new MetricResultDto
|
||||
{
|
||||
Metric = req.Metric, Column = req.Column,
|
||||
Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000
|
||||
Source = tbl, SamplingMs = isFast ? 0 : (tbl == "history_1s" ? 1000 : 60000)
|
||||
};
|
||||
|
||||
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
string tbl = isFast ? "fast_record" : "history_table";
|
||||
|
||||
try
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
if (QV_METRICS.Contains(req.Metric))
|
||||
if (req.Metric == "dynamics")
|
||||
{
|
||||
await DynamicsAsync(res, conn, tbl, req.Column, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
}
|
||||
else if (QV_METRICS.Contains(req.Metric))
|
||||
{
|
||||
if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null)
|
||||
{ res.Status = "error"; res.Error = $"미정의 QV 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
|
||||
res.Unit = qspec.Unit;
|
||||
await ComputeQvAsync(res, conn, tbl, qspec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
_map.TryResolveCleaning(req.Column, out var clean); // 비정상운전 제외 마스크(없으면 null)
|
||||
await ComputeQvAsync(res, conn, tbl, qspec, clean, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -73,24 +84,80 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ──
|
||||
private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s,
|
||||
// ── 동특성(고해상 전용): 하부온도 루프 PV vs OP. 밸브 travel/hunting 진단. FOPDT 모델링 아님(step-test 필요). ──
|
||||
private async Task DynamicsAsync(MetricResultDto res, DbConnection conn, string tbl, string column,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
res.Unit = "OP/h";
|
||||
if (tbl != "history_1s" && tbl != "fast_record")
|
||||
{ res.Status = "no_data"; res.Value = null; res.Error = "동특성은 고해상 소스 필요(history_1s 또는 fast_record). 60초 history 불가."; return; }
|
||||
if (!_map.TryResolveDynamics(column, out var pv, out var op) || pv is null || op is null)
|
||||
{ res.Status = "error"; res.Value = null; res.Error = $"동특성 루프 미정의: {column}"; return; }
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH s AS (
|
||||
SELECT date_trunc('second', recorded_at) ts,
|
||||
max(CASE WHEN tagname=@pv THEN value::float END) pv,
|
||||
max(CASE WHEN tagname=@op THEN value::float END) op
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname IN (@pv,@op) AND value ~ '{NUMERIC}'
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
GROUP BY 1
|
||||
), m AS (
|
||||
SELECT avg(pv) pvm, stddev(pv) pvsd, count(*) n,
|
||||
extract(epoch FROM (max(ts)-min(ts))) dur FROM s WHERE pv IS NOT NULL
|
||||
), seq AS (
|
||||
SELECT ts, sign(pv - (SELECT pvm FROM m)) side,
|
||||
abs(op - lag(op) OVER (ORDER BY ts)) adop
|
||||
FROM s WHERE pv IS NOT NULL
|
||||
), seq2 AS (
|
||||
SELECT side, lag(side) OVER (ORDER BY ts) ps, adop FROM seq
|
||||
)
|
||||
SELECT (SELECT pvsd FROM m), (SELECT n FROM m), (SELECT dur FROM m),
|
||||
count(*) FILTER (WHERE side <> ps AND side <> 0 AND ps IS NOT NULL),
|
||||
coalesce(sum(adop), 0)
|
||||
FROM seq2";
|
||||
AddP(cmd, "@pv", pv); AddP(cmd, "@op", op);
|
||||
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await rd.ReadAsync(ct) && !rd.IsDBNull(1) && rd.GetInt64(1) >= 30)
|
||||
{
|
||||
double pvsd = rd.IsDBNull(0) ? 0 : rd.GetDouble(0);
|
||||
res.N = (int)rd.GetInt64(1);
|
||||
double dur = rd.IsDBNull(2) ? 0 : rd.GetDouble(2);
|
||||
long crossings = rd.IsDBNull(3) ? 0 : rd.GetInt64(3);
|
||||
double opTravel = rd.IsDBNull(4) ? 0 : rd.GetDouble(4);
|
||||
|
||||
res.Value = dur > 0 ? opTravel * 3600.0 / dur : null; // OP travel/h = 밸브 활동(stiction/hunting/마모 proxy)
|
||||
res.Extra["pv_sd"] = pvsd; // PV 변동(고해상)
|
||||
res.Extra["osc_period_s"] = crossings > 0 && dur > 0 ? 2.0 * dur / crossings : null; // 헌팅 주기(s)
|
||||
res.Extra["crossings"] = crossings;
|
||||
res.Extra["dur_s"] = dur;
|
||||
if (res.Value is null) res.Status = "no_data";
|
||||
}
|
||||
else { res.Status = "no_data"; res.Value = null; res.Error = "고해상 표본 부족(≥30s 필요)"; }
|
||||
}
|
||||
|
||||
// ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed. cleaning/drawdown 제외. ──
|
||||
private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s,
|
||||
CleaningSpec? cl, DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
if (s.Kind == QvKind.Single)
|
||||
{
|
||||
var (tot, n, resets) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (tot, n, excl) = await QvDeltaAsync(conn, tbl, s.A, cl, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (tot is null) { res.Status = "no_data"; res.Value = null; return; }
|
||||
res.Value = tot; res.N = n; res.Extra["resets"] = resets;
|
||||
res.Value = tot; res.N = n; res.Extra["excluded_min"] = excl;
|
||||
}
|
||||
else if (s.Kind == QvKind.Ratio)
|
||||
{
|
||||
var (a, na, ra) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (b, nb, rb) = await QvDeltaAsync(conn, tbl, s.B!, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (a, na, ea) = await QvDeltaAsync(conn, tbl, s.A, cl, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (b, nb, eb) = await QvDeltaAsync(conn, tbl, s.B!, cl, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (a is null || b is null || b == 0) { res.Status = "no_data"; res.Value = null; return; }
|
||||
res.Value = a / b; res.N = Math.Min(na, nb);
|
||||
res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["resets"] = ra + rb;
|
||||
// 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등)
|
||||
res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["excluded_min"] = ea;
|
||||
// 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상
|
||||
if (res.Value <= 0 || (s.Max is double mx && res.Value > mx))
|
||||
{
|
||||
res.Status = "no_data";
|
||||
@@ -100,13 +167,13 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
}
|
||||
else // Closure
|
||||
{
|
||||
var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
|
||||
var (feed, nf, ef) = await QvDeltaAsync(conn, tbl, s.A, cl, fromUtc, toUtc, isFast, sid, ct);
|
||||
if (feed is null || feed == 0) { res.Status = "no_data"; res.Value = null; return; }
|
||||
double outSum = 0; int resets = rf;
|
||||
double outSum = 0;
|
||||
for (int i = 0; i < s.Outputs.Count; i++)
|
||||
{
|
||||
var (d, _, ri) = await QvDeltaAsync(conn, tbl, s.Outputs[i], fromUtc, toUtc, isFast, sid, ct);
|
||||
outSum += d ?? 0; resets += ri;
|
||||
var (d, _, _) = await QvDeltaAsync(conn, tbl, s.Outputs[i], cl, fromUtc, toUtc, isFast, sid, ct);
|
||||
outSum += d ?? 0;
|
||||
res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서)
|
||||
}
|
||||
res.Value = 100.0 * outSum / feed; // 폐합 %
|
||||
@@ -114,40 +181,68 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
res.Extra["feed_qv"] = feed;
|
||||
res.Extra["out_total"] = outSum;
|
||||
res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null;
|
||||
res.Extra["resets"] = resets;
|
||||
res.Extra["excluded_min"] = ef;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건,
|
||||
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실.
|
||||
/// 적산 Δ. 비정상운전 분(cleaning=진공高/제품~0, drawdown=feed~0) 제외 후, 정상구간 양(+)증분만 합산(cap 5e4).
|
||||
/// 리셋 3종 자동처리: 999999 wrap·cleaning 리셋·운전조건변경 리셋. cl=null이면 마스크 없이 양증분합산.
|
||||
/// 반환: (Δ합, 정상분수, 제외분수).
|
||||
/// </summary>
|
||||
private static async Task<(double? Total, int NSteps, int NResets)> QvDeltaAsync(
|
||||
DbConnection conn, string tbl, string tag, DateTime fromUtc, DateTime toUtc,
|
||||
bool isFast, int? sid, CancellationToken ct)
|
||||
private static async Task<(double? Total, int NNormal, int NExcluded)> QvDeltaAsync(
|
||||
DbConnection conn, string tbl, string tag, CleaningSpec? cl,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
{
|
||||
bool hasVac = cl != null && !string.IsNullOrEmpty(cl.VacTag);
|
||||
var pivot = new System.Text.StringBuilder("max(CASE WHEN tagname=@tag THEN value::float END) v");
|
||||
var inTags = new List<string> { "@tag" };
|
||||
var mask = new List<string>();
|
||||
if (cl != null)
|
||||
{
|
||||
pivot.Append(", max(CASE WHEN tagname=@ptag THEN value::float END) prod");
|
||||
pivot.Append(", max(CASE WHEN tagname=@ftag THEN value::float END) feed");
|
||||
mask.Add("prod < @pmin OR prod IS NULL");
|
||||
mask.Add("feed < @fmin OR feed IS NULL");
|
||||
inTags.Add("@ptag"); inTags.Add("@ftag");
|
||||
if (hasVac)
|
||||
{
|
||||
pivot.Append(", max(CASE WHEN tagname=@vtag THEN value::float END) vac");
|
||||
mask.Add("vac > @vmax OR vac IS NULL");
|
||||
inTags.Add("@vtag");
|
||||
}
|
||||
}
|
||||
string cleanExpr = mask.Count > 0 ? "(" + string.Join(" OR ", mask) + ")" : "false";
|
||||
string window = isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to";
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
WITH s AS (
|
||||
SELECT recorded_at, value::float v,
|
||||
lag(value::float) OVER (ORDER BY recorded_at) prev
|
||||
WITH pm AS (
|
||||
SELECT date_trunc('minute', recorded_at) ts, {pivot}
|
||||
FROM hc900.{tbl}
|
||||
WHERE tagname=@tag AND value ~ '{NUMERIC}'
|
||||
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
|
||||
), seg AS (
|
||||
SELECT recorded_at, v,
|
||||
sum(CASE WHEN prev IS NOT NULL AND v < prev - 1 THEN 1 ELSE 0 END)
|
||||
OVER (ORDER BY recorded_at) AS seg_id
|
||||
FROM s
|
||||
), perseg AS (
|
||||
SELECT (array_agg(v ORDER BY recorded_at))[1] AS first_v,
|
||||
(array_agg(v ORDER BY recorded_at DESC))[1] AS last_v
|
||||
FROM seg GROUP BY seg_id
|
||||
WHERE tagname IN ({string.Join(",", inTags)}) AND value ~ '{NUMERIC}' AND ({window})
|
||||
GROUP BY 1
|
||||
), fl AS (
|
||||
SELECT ts, v, ({cleanExpr}) AS clean FROM pm WHERE v IS NOT NULL
|
||||
), seq AS (
|
||||
SELECT ts, v, clean,
|
||||
lag(v) OVER (ORDER BY ts) pv,
|
||||
lag(clean) OVER (ORDER BY ts) pc
|
||||
FROM fl
|
||||
)
|
||||
SELECT (SELECT sum(last_v - first_v) FROM perseg),
|
||||
(SELECT count(*) FROM s),
|
||||
(SELECT count(*) FROM s WHERE prev IS NOT NULL AND v < prev - 1);";
|
||||
SELECT coalesce(sum(v - pv) FILTER (
|
||||
WHERE pv IS NOT NULL AND NOT clean AND NOT coalesce(pc, true)
|
||||
AND v >= pv AND v - pv < 50000), 0),
|
||||
count(*) FILTER (WHERE NOT clean),
|
||||
count(*) FILTER (WHERE clean)
|
||||
FROM seq;";
|
||||
AddP(cmd, "@tag", tag);
|
||||
if (cl != null)
|
||||
{
|
||||
AddP(cmd, "@ptag", cl.ProductTag); AddP(cmd, "@ftag", cl.FeedTag);
|
||||
AddP(cmd, "@pmin", cl.ProductMin); AddP(cmd, "@fmin", cl.FeedMin);
|
||||
if (hasVac) { AddP(cmd, "@vtag", cl.VacTag!); AddP(cmd, "@vmax", cl.VacMax); }
|
||||
}
|
||||
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
|
||||
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
|
||||
Reference in New Issue
Block a user