diff --git a/docs/PITOPS-브레인스토밍-웹페이지복사.md b/docs/PITOPS-브레인스토밍-웹페이지복사.md new file mode 100644 index 0000000..82bd78a --- /dev/null +++ b/docs/PITOPS-브레인스토밍-웹페이지복사.md @@ -0,0 +1,263 @@ +Continuous Monitoring: Why One-Time Tuning Is Not Enough +One of the most common mistakes plants make is treating PID tuning as a project rather than a process. A control engineer tunes hundreds of loops during a turnaround, declares the work complete, and moves on. Twelve months later, process conditions have shifted, valve trim has worn, and a significant fraction of those carefully tuned loops are degraded. Yet nothing in the alarm system indicates this — because degraded control performance does not trigger conventional high/low alarms. Energy consumption creeps upward, emissions rates increase, and the plant reports poor progress against its decarbonization targets without understanding why. [6, 8, 9] + +The engineering solution is continuous control loop performance monitoring (CLPM). A CLPM system connects to the DCS or PLC historian in real time and evaluates each loop against a defined set of KPIs — oscillation index, time-in-manual percentage, setpoint tracking error, integrated absolute error (IAE), control valve movement rate, and signal-to-noise ratio. When a loop’s performance grade falls below a threshold, the system flags it for attention, giving the control engineering team a prioritized action list rather than requiring manual trend audits across hundreds of tags. APROMON, PiControl’s AI-based loop monitoring platform, performs exactly this role — connecting to any DCS, PLC, or historian via OPC and distilling over 30 performance criteria per tag into a single intuitive Grade (0–100), while also detecting valve stiction and frozen or noisy sensors before they cause process upsets. [6, 9, 12, 14] + +지속적인 모니터링: 왜 일회성 조정만으로는 충분하지 않은가 +공장에서 가장 흔히 저지르는 실수 중 하나는 PID 튜닝을 하나의 프로젝트로 취급하는 것입니다. 제어 엔지니어는 턴어라운드 중에 수백 개의 루프를 조정한 후 작업이 완료되었다고 선언한 뒤 다음 단계로 넘어갑니다. 12개월이 지난 지금, 공정 조건이 변하고, 밸브 트림이 마모되었으며, 정성껏 조율된 루프의 상당 부분이 열화되었습니다. 하지만 경보 시스템에는 이런 현상이 전혀 나타나지 않습니다. 제어 성능 저하가 기존의 고/저 경보를 작동시키지 않기 때문입니다. 에너지 소비는 점차 증가하고, 배출량은 증가하며, 발전소는 탈탄소화 목표에 대한 진전이 저조하다고 보고하는데, 그 이유를 이해하지 못하고 있습니다. [6, 8, 9] + +공학적 해결책은 연속 제어 루프 성능 모니터링(CLPM)입니다. CLPM 시스템은 DCS 또는 PLC 히스토리안과 실시간으로 연결하여 각 루프를 정해진 KPI 집합에 대해 평가합니다 — 진동 지수, 수동 시간 비율, 설정지점 추적 오류, 통합 절대 오차(IAE), 제어 밸브 이동률, 신호 대 잡음비 등. 루프의 성능 등급이 임계값 이하로 떨어지면 시스템은 이를 주의 대상으로 표시하여 제어 엔지니어링 팀에 수백 개의 태그에 대한 수작업 추세 감사를 요구하지 않고 우선순위 처리된 작업 목록을 제공합니다. PiControl의 AI 기반 루프 모니터링 플랫폼인 APROMON은 바로 이 역할을 수행합니다 — OPC를 통해 DCS, PLC, 또는 역사자와 연결하여 태그당 30개 이상의 성능 기준을 하나의 직관적인 등급(0–100)으로 추출하고, 밸브 고착과 얼거나 잡음이 나는 센서가 공정 문제를 일으키기 전에 감지합니다. [6, 9, 12, 14] + + + +Multivariable Closed-Loop System Identification, Multi-Objective PID Tuning, PLC/DCS-based Advanced Process Control (APC) Design & Optimization, and Model Predictive Control (MPC) Maintenance Technology +Necessary Technology for all Process Control and Automation Engineers +View the Pitops brochure. + +Please contact us to get a free demo on PITOPS - Multivariable Closed-Loop System Identifier, PID Tuner, Advanced Process Control (APC) Designer and Optimizer - Artificial Intelligence (AI) based Algorithm. + +info@PiControlSolutions.com, Tel: (832) 495 643 + +Many PID control loops are not fully tuned and optimized due to lack of awareness of the potential and the resulting benefits. If PID control loop does not perform optimally, it can result with product quality reduction, off-spec products and other. Overall, the studies show that between 65 to 85 % of PID control loops can be improved. If PID control philosophy is understood clearly, it could provide tremendous benefits, control improvements and a financial rise. Knowledge of System Identification (or Model Identification) gives powerful capability to any process control or chemical engineer to: + +mathematically calculate PID tuning parameters for any controller +mathematically calculate PLC/DCS-based Advanced Process Control (APC) parameters +mathematically calculate new Model Predictive Controller (MPC) models +mathematically simulates any what-if process control scenario and outcomes +Optimal PID controllers, appropriately designed PLC/DCS-based Advanced Process Controllers (APC) and/or Model Predictive Controllers (MPC) can help any industrial plant to: + +Maximize production +Minimize utilities +Minimize waste and off-spec product +Reduce unplanned shut-downs +Provide faster grade transitions +Achieve faster new conditions +Improve stability and increasing safety +Assist operator to avoid mistakes +Improve automation level +There are a few ways how to do proper system identification and certain tools are available for this purpose, but unfortunately most of the them needs step-tests on PID OP in Manual mode (Open-Loop step-tests), and a few of them can do it by having-step tests on PID SP in Auto mode (Closed-Loop step-tests). These step tests many times are not possible, time-consuming or plant intrusive, and engineers and operators do not like them a lot. During steps-tests the plant many times will be in upset or spec-off mode, or even working at lower capacity than usual. + +PITOPS from PiControl is the only technology across the globe which can do multivariable complete closed-loop system identification. The ability to identify open loop process models using completely closed loop data with PID control loops in Auto or even Cascade mode, and with APC schemes active or MPC model predictive controller ON and active is a unique and novel offering from PiControl, state of the art technology. Such functionality is helpful not only for PID tuning optimization for making PID tuning improvements but also to improve APC performance by optimizing APC parameters (like feedforward, inferential, constraint override and other APC parameters), and to maintain and improve existing Model Predictive Control (MPC) models. + +Schedule A Demo +PITOPS +The model improvement functionality uses the new and improved Artificial Intelligence (AI) based algorithm optimization technique that proves to be superior to FIR (Finite Impulse Response), ARMAX (Auto Regressive Moving Average Models with Exogenous Inputs) Box Jenkins methods, which are commonly used by all other competitors including Honeywell, Aspen, Emerson, Yokogawa, MathWorks – all process control design, DCS vendors and PLC vendors. It is capable to automatically identify one or several unmeasured process disturbances, isolate their pattern, display as a trend, and save in the Excel. Works well admits fast random noise, medium frequency drifts and slow unmeasured disturbances. + +PITOPS can take past data from any PLC or DCS historian during start-up/shutdown or normal plant operation and without any additional time-consuming and intrusive step tests can identify true and accurate process models from: + +PID loops being complete in Auto or even in Cascade mode (where the user does not have to break the Cascade chain or set the loop in Manual). +Open-loop (step changes of PID OP) or complete closed-loop data (step changes of PID SP). +Ultra-short duration data (1/5th of data). +Data having process or equipment nonlinearities involved. +Complex and ugly closed-loop data without any need for data preconditioning (resample, high noise, missing data, outliers, etc.). +PID loops impacted with high noise and unmeasured disturbances in Auto/Cas mode. +PID loops having valve issues (stiction/hysteresis) and running in Auto or Cas mode. +PID loops being completely oscillatory (unstable) in Auto/Cas mode. +Running MPC controller. +PITOPS_1Pi-control-img-4 +All mentioned options above reduce intrusive and time-consuming plant step-tests and save the plant of running in undesirable conditions. + +On the other hand, engineers still like to use not so effective old fashion PID tuning rules, where they need to conduct many time-consuming and intrusive plant step-tests, break a control chain, switch control loop modes, and eventually hope that during step-testing time plant will not be hit by unmeasured disturbance which will disturb the plant and ruin performing plant step-tests. + +Also, each PID control loop has its own purpose and objective. The optimal tuning of critical loops must consider the nature of the process, how fast the control valve can be allowed to move, the nature of known and unknown disturbances and other customs issues. In any industrial plant there are PID control loops which: + +Do not change their setpoints regularly (mostly barely) +PID loops which have continuous and dominant disturbances +PID loops which have complex setpoint trajectory changes (like all Cascade PID loops) +PID loops which have valve issues +PID loops which valves cannot be changed abruptly since it may cause some serious downstream upsets +Therefore, it is unreasonable to tune all PID control loops only based on the step setpoint change, like many tools do. This simplified PID tuning on a typical step setpoint change many times will produce poor PID control loop behavior sudden and unexpected process disturbances rejection, and/or control valve mechanical issues. + +Schedule A Demo +Except being able to do powerful system identification, PITOPS can also do multi-objective PID loop tuning. It can accurately tune PID control loops based on the following multi-objective approach: + +Step/ramp or even complex SP changes (Cascade loops) +Disturbances (Pulse, Step, Ramp, Sine) +Noise +PID OP rate of change +Control valve stiction +Non-linear process gains and/or process dynamics + +Now, process control user/expert can finally get optimal PID tuning parameters based on real PID loops needs and objectives, and stop the usage of trial-and-error PID tuning. + +PITOPS also provides powerful capabilities for designing and implementing Advanced Process Control (APC) schemes inside of any PLC or a DCS. It helps to precisely identify process dynamics required for optimizing the following schemes: + +Multiple cascade PIDs - It can optimize both slave and cascade controllers. + +Split range control +Ratio control +Fan-out control +Inferential control +Deadtime compensated (DTC) controller +Internal model control (IMC) +Production rate maximizer controllers +Discrete slow loops, like GC analyzer sample time delay +Special transforms like natural logarithms, square and square root to linearize commonly known non-linear processes, such as for constraint control for distillation column delta pressure to infer column flooding limits and for tighter control of tall Superfractionators where the distillation purities behave non-linearly. +Feedforward controllers – It automatically optimizes controller parameters for a closed-loop simulation configured with a disturbance and feedforward model precisely matching the process dynamics. +Figure-6.-Cascade-Simulation-and-Control-OptimizationPi-control-imgPITOPS_2 +Some of the distinguishing and powerful features of PITOPS are listed below: + +Simultaneous, multi-variable identification with multi-inputs, handles both SISO (single-input, single-output) and MISO (multi-input single-output) control problems. +Identifies Control Valve Stiction or Deadband. +Runs all in the time domain, no complicated discrete (Z) domain knowledge required. +Equipped with the powerful constrained nonlinear optimizer to identify process dynamics. +Allows you to easily conduct “what-if” simulation studies by specifying guessed values of transfer function parameters and to even compare predicted models with other data sets not used in the dynamic estimation. +Works from fast millisecond scan times to seconds, minutes, and multiples of minutes. This allows simulation from super-fast compressor-surge control loops to very slow distillation column online analyzer-based purity control loops. +Optimizes PID tuning parameters to improve control action amidst control valve problems such as stiction and deadband. +Possesses all commercially available PLC/DCS PID algorithms. PID equation may be in ideal, interactive, parallel, series, integral only, proportional and derivative only and other different formats. PID equations format may be using error or PV on the proportional action and/or derivative action. All PLC/DCS forms of PID equations are supported. +Using regressed, empirical, semi-empirical or rigorous chemical engineering models, effective model-based dynamic controllers can be easily implemented. +Integrating, first-order, second-order, and open-loop unstable with dead time transfer function simulations and identification possible. +Simulation and optimization of random (white) noise, precisely matching the actual noise level seen on PLC/DCS. +Control valve characterization and Gap action control simulation. +Can be also used for process control training for process control engineers, process engineers, DCS and PLC technicians and for process control semester classes at colleges and universities. +Schedule A Demo +PITOPS Question & Answers + +What is PITOPS, and how does it support PID controller tuning? +PITOPS is industrial multivariable closed-loop system identification, multi-objective PID tuning, and PLC/DCS-based APC design and optimization technology. PITOPS software is used for real-time PID loop tuning and model-based control. It supports DCS and PLC platforms by simulating time-domain control responses using real plant data to deliver optimal tuning parameters and controller performance. + +Does PITOPS support both DCS and PLC environments? +Yes, PITOPS is compatible with all major DCS and PLC vendors. It offers vendor-specific PID logic emulation and tuning units to match real-world PID loop dynamics. + +How does PITOPS ensure compatibility with different DCS vendor PID equations? +PITOPS includes a prebuilt library of PID structures and allows users to select or request custom-tuned logic blocks for full compatibility. + +What makes PITOPS different from other PID tuning software? +PITOPS: + +Runs as offline (Excel) instance + +Allows system identification based on completely oscillatory closed-loop data + +Allows system identification using non-steady-state closed-loop data + +Allows system identification based on cascade closed-loop data + +Does multivariable system identification using closed-or-open loop data while MPCs are runing + +Can do control valve stiction identification and PID optimization based on it + +Can do unmeasured disturbance pattern identification + +Does PID control loop optimization based on different disturbances + +For what is PITOPS used in industrial process control? +PITOPS is a control engineering software tool for identifying dynamic process transfer functions from plant data, enabling accurate PID controller tuning and model-based APC optimization using real-time operational data from DCS or PLC systems. + +How does PITOPS simulate advanced process control (APC) schemes? +PITOPS supports APC design by modelling multivariable process interactions and allowing optimization of cascade control, feedforward, and model-driven strategies using simulation. + +Can I use PITOPS to tune cascade and feedforward controllers? +Yes, PITOPS supports tuning of single-loop and multiloop controllers with high accuracy and configurability. + +Is PITOPS suitable for fast or millisecond-scale loops like compressor surge control? +Yes, PITOPS can simulate fast loop dynamics using sub-second or millisecond resolution, making it ideal for high-speed control loops. + +Can I use PITOPS to identify transfer functions from plant data? +Yes, the PITOPS suite includes a TFI module to identify process transfer functions from real data. + +How does PITOPS differ from traditional system identification tools? +Unlike frequency-domain tools, PITOPS performs system identification entirely in the time domain, making it more intuitive for control engineers and plant technicians without requiring deep academic control theory knowledge. + +Can PITOPS handle multivariable systems and closed-loop data? +Yes, PITOPS supports SISO and MIMO modelling using both open-loop and closed-loop plant data, including noisy signals and disturbance events. + +What types of transfer functions can PITOPS identify? +It can identify first- and second-order dynamic models, ramp-type behaviours, and systems with dead time or non-linearities, including combinations impacted by actuator stiction or sensor drift. + +What are the maximum data limits for analysis in PITOPS? +PITOPS can process up to 100,000 rows of historical or live plant data, enabling analysis of both short-term and long-duration process behaviours. + +How do I improve the accuracy of transfer function identification in PITOPS? +Use the zoom and TTSS (Time to Steady State) tools to select relevant process data segments, and initialize reasonable initial estimates for model parameters such as gain, delay, and time constant before identification. + +What model validation criteria does PITOPS use? +PITOPS uses goodness-of-fit metrics such as FIT (%), IAE (Integrated Absolute Error), and NRMSE (Normalized Root Mean Square Error) to evaluate the accuracy of identified models. + +What kind of data format is required for PITOPS to identify transfer functions? +PITOPS accepts Excel or CSV files where process measurement (PV), control input (MV), and disturbance input (DV) are structured in columns starting from row 4. The first column is typically a timestamp and is ignored. + +What are the system requirements for running PITOPS? +PITOPS requires a Windows-based system, minimum 4MB RAM, and 500MB of disk space, and a full HD resolution. It is lightweight PID tuning software. + +Is the software compatible with international regional settings? +Yes, PITOPS supports global numerical formats and time standards, but for best results, it is recommended to use U.S. English local settings. + +Can I select time units in milliseconds, seconds, or minutes? +Yes, PITOPS supports millisecond to minute-level time unit resolution depending on your data. Ensure consistency when defining delay and time constant parameters. + +What is the difference between CV1, CV2, and MV1, MV2 and MV3 in PITOPS? +CV1 is the primary output (controlled variable) used for model identification. CV2 is optional for comparison. MV1 to MV3 are independent manipulated input signals used to model the process behaviour. + +Can I save and reuse identification cases? +Yes, you can save your work as a .TF project file with embedded notes for future analysis and modification using the “Save Case File“ and “Add Notes“ options. + +How is the training structured—live, self-paced, or hybrid? +PITOPS training is available on-demand with instructor support via email or live Q&A. It includes recorded sessions, quizzes, and interactive process control exercises. + +----------------------------------------------------------------- + +PID Control Loop Tuning Consulting and Improvements +Home | Services | PID Control Loop Tuning Consulting and Improvements + +Request More InFo +PiControl Solutions offers PID control loop tuning consulting services to help you optimize the performance of your industrial processes. +We can help you achieve the following benefits from PID controller optimization: + +Improved process stability +Higher product quality and consistency +Reduced waste and energy consumption +Increased productivity and profitability +Enhanced safety and reliability +Extended equipment life and reduced maintenance costs +PID stands for proportional-integral-derivative, which are the three terms that make up a PID controller, a typical element of industrial automation. A PID controller is a feedback control mechanism that adjusts the output of a system based on the difference between the desired setpoint and the measured process variable. PID controllers are widely used in various industrial process control applications that require precise and stable control, such as temperature, pressure, flow, level, and more. + + +PID control loop tuning is the process of adjusting the parameters of the PID controller to achieve the best possible response from the system. Tuning a PID controller involves finding the optimal values for the proportional gain, the integral time, and the derivative time, which are the factors that determine how much the controller reacts to the error, the accumulated error, and the rate of change of the error, respectively. The goal of tuning is to minimize the error, the overshoot, the oscillation, and the settling time of the system, while maximizing the process stability, the robustness, and the efficiency of the system. + +We have a team of qualified engineers with 30+ years of hands-on control room experience, 500+PID tuning projects done, 10,000+ tuned control loops, 100+Advanced Process Control (APC) projects, who will help you with PID control optimization for your specific application. Our expertise covers many DCSs and PLCs, such as Honeywell TDC3000, Honeywell Experion , Yokogawa, ABB, Emerson DeltaV, Foxboro IA, Allen Bradley PLC, Siemens PLC, and others. + + +During the tuning project we will analyze your system, define wanted controller behavior after tuning, apply the best tuning method, and implement the optimal PID parameters for your controller. In our control system consulting projects we use our own software tool, PITOPS, superior to any other tool on the market. Using our Control Loop Performance Monitoring (CLPM) software APROMON, we can also provide you with a loop performance analysis that shows the performance of your system before and after tuning, as well as recommendations for future improvements. PID control tuning projects can be conducted as on-site and remote consulting, according to the client’s preference. + +------------------------------------------------------------------------ +MPC Model Predictive Controller Maintenance +Home | Services | MPC Model Predictive Controller Maintenance + +Request More InFo +Model Predictive Controller (MPC) Maintenance and Improvement Services from PiControl Solutions +Please contact us to get free trial software. +info@PiControlSolutions.com, Tel: (832) 495 6436 + +Improve your model predictive controller (MPC) performance using PiControl Solutions software and technology. PiControl offers remote model predictive controller (MPC) maintenance consulting and support. Any model predictive controller (MPC) software – DMC (Dynamic Matrix Control), RMPCT (Robust Model Predictive Control Technology), Predict Pro, Connoisseur, or any other can be improved. PiControl software is called COLUMBO – closed loop universal multivariable optimizer. COLUMBO reads Excel data files containing MV, CV and FF data from the model predictive controller (MPC). Its powerful optimizer then refits existing MPC models and improves MPC models accuracy thereby reducing prediction errors. A fast, powerful, novel, revolutionary method of improving any MPC models using a totally new methodology not available in any other product. + +Model predictive controller (MPC) performance may be poor because of any of the following: + +1) Incorrect MPC dynamic models. + +2) Poor selection of MV and CV variables inside of model predictive controller (MPC). + +3) Poor PID tuning for slave PIDs connected to the model predictive controller (MPC). + +4) Certain CVs and MVs in current model predictive controller (MPC) that need to be relocated to DCS-based APC (advanced process control). + +5) Judicious selection and deletion of dynamic MPC models. + +6) Combining process engineering knowledge to improve model predictive controller (MPC) performance. + +PiControl can help you in checking your model predictive controller (MPC) and fixing all six above listed opportunities. + +Schedule A Demo +most-common +mpcsay-goodbye +COLUMBO just reads offline Excel files, does not need to connect to the Level 3 process control network – so there are no cyber security concerns! + +COLUMBO can read Excel files with MV, CV and FF (manipulated variable, controlled variable and feedforward variable) data with the model predictive controller (MPC)ON (active and in closed-loop mode). With the normal changes in CV targets limits (setpoints) or the normal changes in DVs (disturbance variables), COLUMBO optimizer can determine new and improved models with superimposing new intrusive step tests on the setpoints of the slave PIDs. Not needing new intrusive step tests prevents plant perturbations and disturbances and reduces stress and headaches for the control room operator. + +analysis-report +PiControl can help you improve your model predictive controller (MPC) remotely – no travel costs, all work can be done safely and remotely. Contact PiControl at Info@PiControlSolutions.com and let us help you improve your model predictive controller (MPC) performance today. Increase plant profits and benefits using COLUMBO software from PiControl. + + + + + diff --git a/docs/작업지시서-STEAM-SP-FF통합.md b/docs/작업지시서-STEAM-SP-FF통합.md new file mode 100644 index 0000000..4a2d4d3 --- /dev/null +++ b/docs/작업지시서-STEAM-SP-FF통합.md @@ -0,0 +1,34 @@ +# 작업지시서 — STEAM SP를 FF 추종 프레임에 통합 (2026-06-06) + +> 설계 `docs/학습형제어-오퍼레이터모방-플랜.md`, 메모리 `learned-control-operator-imitation.md`. +> 관련 `docs/작업지시서-operator-assist-웹패키징.md`(웹 시각화는 그쪽). + +## 목적 +SteamAdvisor(steam=f(feed,product,T_C)→OP)를 **기존 FF 추종-WriteGuard 파이프에 한 행으로** 통합. +FF가 FEED·STREAM SP는 제안하나 **STEAM 제안이 비어있음**(SteamOp를 모니터로만 읽음) — 그 빈자리를 채움. + +## 현 자산 (재활용, 중복금지) +- `ColumnConfig.SteamOpTag`(이미 존재, TICA-x111A.OP), `PvSnapshot.SteamOp`(읽기만). +- `FeedforwardSupervisor`(advisory 생성), `FeedforwardWriteGuard`(SP/OP 쓰기 보호), `FfTrackingStore`(추종/로깅), 개별 follow 토글. +- `SteamAdvisor.Predict(feed,product,tC)→{RecOp, RecSteam, Confidence, Ood, Mode}`(완성). + +## 단계 +1. **모델 합류**: `AdvisoryResult`에 `SteamRecommendedOp`/`SteamRecommendedFlow`/`SteamConfidence`/`SteamOod` 추가. `FeedforwardSupervisor`가 PvSnapshot의 feed(FICQ-x101)/product(x118)/T_C(TI-x111C)로 `SteamAdvisor.Predict` 호출 → advisory에 실음. +2. **SP→밸브 연계**: RecOp = `SteamOpTag`(TICA-x111A.OP) write 대상. 현장 MANUAL → TICA를 MAN 유지하고 OP 직접 write(온도PID 미경유, 현장 불신 존중). RecSteam은 표시용. +3. **추종선택+안전**: STEAM도 stream처럼 "추종 ON/OFF" 1행 추가. `FeedforwardWriteGuard`에 SteamOp 쓰기 대상 등록(rate-limit·클램프·데드밴드). OOD/비-PROD → 추종 자동 보류(폴백). 기본 advisory_only. +4. **로깅**: `FfTrackingStore`에 권장OP vs 실제OP 기록(shadow 신뢰리포트). +5. **컬럼별 모델**: ColumnConfig에 `SteamModelPath` 추가(컬럼별 c{prefix}_model.json). 6-1차 first-cut. + +## 검증기준 +- 6-1차 advisory에 STEAM 행 표시(권장OP+신뢰도). 추종 OFF시 write 0. +- 추종 ON + in-envelope: WriteGuard 통과해 OP write, rate-limit 준수. +- OOD/STARTUP: "범위밖→수동" 폴백, write 차단. + +## 주의 +- ★closed-loop 강제 금지(메모리: 조건 실변동중). 오퍼레이터 승인·개별 follow 존중. +- TICA MAN 모드 확인 후 OP write(AUTO면 온도SP 경유 — 별 옵션). +- range는 realtime PV_HighRange/LowRange live(xlsx 하드코딩 금지). +- 6차만 C3 online → first-cut 6차. 9·10차(C4 미연결)는 백테스트만. + +## 권장순서 +①AdvisoryResult+Supervisor 합류 → ②WriteGuard 등록+follow 토글 → ③UI 행 → ④로깅. ①②는 웹패키징과 독립 병행 가능. diff --git a/docs/작업지시서-operator-assist-웹패키징.md b/docs/작업지시서-operator-assist-웹패키징.md new file mode 100644 index 0000000..3c243c7 --- /dev/null +++ b/docs/작업지시서-operator-assist-웹패키징.md @@ -0,0 +1,169 @@ +# 작업지시서 — operator-assist 웹 패키징 (2026-06-06) + +> 전체설계 `docs/학습형제어-오퍼레이터모방-플랜.md`, 이전단계 `docs/작업지시서-학습형제어-다음단계.md`. +> 메모리 `~/.claude/.../memory/learned-control-operator-imitation.md`. + +## 0. 컨텍스트 (필독) + +- **오프라인 분석 완주**: 7 train(6-1·6-2·8·9-1·9-2·10-1·10-2) prodmap·shadow·rolling·startup·shutdown 완료. 5차=운전부재 제외. 10차 둘=시운전 신뢰낮음. +- **선결 클린징 완료(2026-06-06)**: `gen_instrument_ranges.py`→`instrument_ranges.json`(태그별 계기 EU range). `c6111_extract.clip_to_ranges`가 range밖 스파이크→NaN. 전 컬럼 envelope 정상화(9-2 feed 558332→453.6). 8차 부수개선 R²0.630→0.831. +- **range 소스 원칙**: realtime_table `PV_HighRange/LowRange`(실측, online 5·6·8차 권위) > xlsx `InstructionsDisplayNumber`(미연결 9·10차 fallback). ★**C# 운영엔 xlsx 하드코딩 금지** — realtime live로 읽음. +- **(a)+(b) 통합 이유**: 라이브 advisory 시각화는 (a)패키징과 (b)C#live가 한 덩어리. 분리하면 (a)는 그릴 라이브데이터 없고 (b)는 화면 없음. +- **차트 라이브러리 기보유**: `echarts.min.js`(trend.js 사용, 산점/막대/정렬), `uPlot.iife.min.js`(fast.js, 조밀 시계열 33만점). 신규 의존성 0. + +## 목표 +정적 png 분석을 **라이브 advisory 웹 대시보드 + 백테스트 차트**로. write 금지(advisory_only). + +--- + +## 레이어 ① Python 좌표 JSON export [(b) 무관, 먼저 가능] + +**목적**: png 만들던 분석이 좌표 배열 JSON도 동시 export → 웹이 그대로 렌더. + +**단계**: +1. 각 분석 스크립트(`c6111_prodmap`/`startup`/`operator_assist`)에 `--json` 출력 추가, 또는 신규 `export_plotdata.py`로 일괄. +2. 컬럼별 `c{prefix}_plotdata.json` 산출: + - prodmap: 운전점 산점(feed,steam_flow), valve곡선(op,flow_up/dn), pred-vs-actual, feature importance. + - startup: 컷인정렬 다중에피소드 시계열(rel분, reb/steam/reflux/feed), 컷인 트리거(reb-A 84.6±0.5, ΔT 1.9±0.4). + - shadow: advisory-vs-actual OP **다운샘플(LTTB or 6h)**, error 히스토그램 bins(서버집계). +3. 33만행 원본 금지 — 반드시 서버 다운샘플. + +**검증**: JSON으로 matplotlib png 재현 가능. + +--- + +## 레이어 ② C# 백엔드 [(b) 본체] + +**현 상태**: `SteamAdvisor.cs` Predict단건 + `/api/steam/predict`(수동입력)만. **live연결·로깅·UI 전무**. + +**단계**: +1. **라이브**: 실시간 태그(feed FICQ-x101, product x118, T_C TI-x111C) 자동 read→`Predict`→시계열 버퍼/`FfTrackingStore` 로깅. `GET /api/steam/live`(현재 모드/권장OP/신뢰도/최근N분 추이). range=realtime `PV_HighRange/LowRange` live. +2. **백테스트**: `c{prefix}_plotdata.json` 서빙 `GET /api/steam/backtest/{col}`. +3. 모드분류(PROD/STARTUP…) + OOD게이트 C#에 이미 포팅됨(envelope=데이터quantile, xlsx 아님). + +**검증**: live 태그로 advisory 산출·로깅. OOD시 "범위밖→수동" 표시. write 0건. + +**주의**: 6차만 online(C3). 9·10차(C4)는 데모 미연결 → 백테스트 차트만, 라이브 패널 6차 우선. + +--- + +## 레이어 ③ 프론트 [②후] + +**단계**: +1. `wwwroot/js/steam.js` + `panes/steam.html`, index.html 등록. +2. **라이브 패널**(uPlot): 권장 vs 실제 OP 추이, 모드/신뢰도 배지, 권장OP 게이지. +3. **백테스트 탭**(ECharts): prodmap 산점, startup 정렬, shadow 시계열+히스토그램. 컬럼 선택. +4. 호버 툴팁(feed/product/T_C/권장OP), 줌/팬. + +**검증**: png 수준 그래픽 + 인터랙티브. 컬럼 전환 동작. + +--- + +## 권장순서 +①(Python export) → ②(C# live+백테스트 API) → ③(프론트). ①은 (b) 무관해 즉시 착수 가능. + +## 검증기준 (전체) +- 라이브: 6차 advisory 표시·로깅, OOD폴백, write 0. +- 백테스트: 전 컬럼 차트 png 동급. +- first-cut 라이브 컬럼: **6-1차**(shadow94%·advisory92.2% 최상, C3 online). + +--- + +## 진단보고서 (diagnosis-checklist.md STEP 1-8 완료, 2026-06-06) + +### 맥락 (STEP 1-2) + +| 계층 | 진단 대상 | 상태 | +|------|----------|------| +| ① Python export | `export_plotdata.py` 없음. 모든 분석 스크립트 PNG만 출력 | **미착수** | +| ② C# backend | `SteamAdvisor.cs` Predict+controller 있음. live/backtest API 없음 | **일부 완료** | +| ③ Frontend | `steam.js`/`steam.html` 없음, index.html 미등록 | **미착수** | + +관련 문서: `docs/학습형제어-오퍼레이터모방-플랜.md`(설계), `docs/작업지시서-학습형제어-다음단계.md`(이전단계). 전 단계 작업4에서 SteamAdvisor.cs+컨트롤러까지 포팅 완료. + +### 호출 계층 지도 (STEP 3-4) + +``` +# C# 현황 +GET /api/steam/predict?feed=&product=&tC= + → SteamAdvisorController.Predict() + → SteamAdvisor.Predict() + → ClassifyMode() (hardcoded thresholds) + → InEnvelope() (envelope dict) + → PolyVal() (3차 valve inverse) + → return SteamAdvisoryResult + +# Layer ② 구축 필요 +Hc900RealtimeService (1s polling) → realtime_table (FICQ-x101/x118/TI-x111C) + → 새로운 background service: live read → Predict → 버퍼/로깅 + → GET /api/steam/live (현재 advisory) + → GET /api/steam/backtest/{col} (plotdata.json 서빙) + +# Layer ③ 구축 필요 +index.html → steam.js + steam.html + → live 패널 (uPlot) : 권장OP vs 실제 OP 추이 + → backtest 탭 (ECharts) : prodmap/startup/shadow 차트 +``` + +### 체크리스트 결과 (STEP 5-7) + +#### 🔴 HIGH (0건) +해당 없음 — 모든 작업은 신규 개발, 기존 운영코드 변경 없음. + +#### 🟠 MED (2건) + +##### M1. Predict 파라미터 검증 없음 + +**문제**: `SteamAdvisorController.Predict()`에서 feed/product/tC에 `[Required]`·`[Range]` 미적용. 기본값 0이 그대로 전달되어 NaN이 아닌 0으로 예측 수행 → "정상"으로 오인 가능. +**근거**: `SteamAdvisorController.cs:24-27` — `[FromQuery] double feed`에 검증 속성 없음. +**영향**: 잘못된 입력(0, 음수)이 조용히 Predict 통과 → 신뢰도 HIGH로 표시되어 오퍼레이터 혼동. +**수정**: `[FromQuery][Required][Range(0,2000)] double feed` 등 검증 추가, 실패 시 400 반환. + +##### M2. ClassifyMode 임계값 컬럼 종속 — 다중 컬럼 대응 필요 + +**문제**: `ClassifyMode(feed, product, tC)`가 6-1차 하드코딩(product>100=PROD, feed>50=PROD, tC>60=LINEOUT). 8차(공급원 C4) 등 다른 컬럼은 product 스케일·feed 범위가 다름. +**근거**: `SteamAdvisor.cs:121-127` — 임계값 상수. `SteamModel.Column` 속성은 존재하나 미사용. +**영향**: 8/9/10차 백테스트 차트에서 모드분류 오동작 → 잘못된 advisory 표시. +**수정**: `SteamModel`에 `ModeThresholds { FeedProdMin, ProductProdMin, TcLineoutMin }` 추가, `ClassifyMode`가 모델별 임계값 사용. 6-1 기본값 유지. + +#### 🟡 LOW (3건) + +##### L1. 모델 경로 절대경로 하드코딩 + +**문제**: `appsettings.json`과 `SteamAdvisor.cs` 기본값 모두 `/home/windpacer/projects/hc900_ax/...` 절대경로. 배포 서버에서 동작 불가. 또한 단일 모델만 로드 — 백테스트 API에서 컬럼 전환 불가. +**근거**: `appsettings.json:68`, `SteamAdvisor.cs:44`. +**영향**: 배포 시 수동 경로 수정 필요. 백테스트는 별도 컬럼별 모델 로드 로직 추가 필요. +**수정**: (a) 상대경로 `scripts/analysis/{column}_model.json` 패턴 사용, (b) `LoadModel(path)`로 동적 로드 지원(이미 있음), (c) 백테스트용 `GetModel(column)` 추가. + +##### L2. Python 스크립트 BASE 경로 하드코딩 + +**문제**: `c6111_prodmap.py:20`, `c6111_operator_assist.py:14`, `c6111_shadow.py:15` 등 모든 분석 스크립트가 `BASE = "/home/windpacer/.../scripts/analysis/"` 절대경로. `--data` 인자로 우회 가능하나 기본값이 깨짐. +**근거**: 각 스크립트의 `BASE` 상수. +**영향**: 다른 환경에서 `--data` 생략 시 FileNotFoundError. PNG 저장도 BASE 경로에 고정. +**수정**: `os.path.dirname(os.path.abspath(__file__))` 기반 상대경로 또는 인자화. + +##### L3. PredictAsync가 동기 래퍼 + +**문제**: `PredictAsync()`가 `ValueTask.FromResult(Predict(...))` 반환 — 실제 비동기 동작 없음. CPU-bound이므로 sync가 적절하나 Async 접미사 오해 소지. +**근거**: `SteamAdvisor.cs:115-119`. +**영향**: 성능 영향 없음. 호출자 입장에서 async로 오인 가능. +**수정**: (현상 유지 권장) 주석 추가 또는 sync `Predict()` 직접 호출. + +### 교차 검증 완료 (STEP 6) + +| 항목 | Q1. 이미 수정? | Q2. 타 레이어 처리? | Q3. 의도적 설계? | Q4. 재현 시나리오? | 판정 | +|------|:---:|:---:|:---:|:---:|:---:| +| M1. 파라미터 검증 | 아니요 | `SteamAdvisorController` 자체에 없음 | 아니요 | feed=0 전송 시 0으로 예측 → HIGH 표시 | **MED** | +| M2. 모드 임계값 | 아니요 | `SteamAdvisor` 단독 | 6-1 pilot은 의도적 | 8차 백테스트에서 PROD 미인식 | **MED** | +| L1. 모델 경로 | 아니요 | `IConfiguration` 주입 | 아니요 | 백테스트 컬럼 전환 시 FileNotFound | **LOW** | +| L2. BASE 경로 | 아니요 | CLI 기본값 | 편의상 | `--data` 생략 시 오류 | **LOW** | +| L3. PredictAsync | 해당 없음 | 해당 없음 | CPU-bound sync = 의도적 | 없음 | **제외(Q3)** | + +### 자가 검증 (STEP 8) + +- [x] 각 지적 사항을 현재 파일 몇 번 줄로 직접 가리킴 +- [x] HIGH 항목 없음 — 신규 개발이므로 기존 운영코드 변경 없음 +- [x] 교차 검증 4개 질문 모두 통과한 항목만 포함 +- [x] 수정 예시가 현재 코드에 아직 적용되지 않은 내용 +- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다" 혼동하지 않음 + diff --git a/docs/작업지시서-측류SP쓰기-피드램프실행.md b/docs/작업지시서-측류SP쓰기-피드램프실행.md new file mode 100644 index 0000000..274823a --- /dev/null +++ b/docs/작업지시서-측류SP쓰기-피드램프실행.md @@ -0,0 +1,317 @@ +# 작업지시서 — 측류SP쓰기-피드램프실행 (진단 재검토 v3) + +> 대상: 구현 LLM / 개발자 +> 작성 기준 코드: `src/Hc900Crawler`, `src/Infrastructure/Control`, `src/Core/Application/Feedforward` +> 전제: 이 저장소(hc900_ax)의 FF(Feedforward) 모듈 구조를 그대로 따른다. +> 진단일: 2026-06-04 → **v3 재검토: 진단 10건을 코드와 대조해 재분류(아래 "재검토 결과")** + +--- + +## 진단 재검토 결과 + +원 진단(v2)은 코드와 대조 시 **실재 결함 1건 + 위험한 오처방 1건 + 사실오류/비이슈 2건 + "신규 구현을 결함으로 오분류" 6건**으로 정리된다. 아래 표가 정정본이며, 이후 본문은 이 분류를 따른다. + +### (1) 실재 결함 — 반드시 수정 + +| # | 항목 | 정정 심각도 | 처리 | +|---|------|------------|------| +| D-3 | `WriteSp` L102 `"HC1"` 하드코딩. 실 컨트롤러 ID는 **C1~C4**이므로 `"HC1"`은 어느 설정에도 매칭 안 됨 → `GetClient`가 `"컨트롤러 HC1 설정 없음"` 예외 → **현재 측류 쓰기는 사실상 전부 실패 중일 개연성**. "멀티컨트롤러 미지원"이 아니라 "단일 쓰기조차 깨짐" | 🔴 HIGH | §5 A-1 | + +### (2) 증상은 맞으나 처방이 위험 — 처방 교체 + +| # | 항목 | 판정 | 처리 | +|---|------|------|------| +| D-1 | `WriteGuard.Check`가 `AdvisoryOnly`면 버튼 쓰기도 차단 — 증상은 사실. **그러나 v2 처방("WriteGuard 우회, SpMin/Max·Commanded·SpNodeId만 검증")은 채택 불가**: `Valid`(stale/무효)·`Grade==C`(저신뢰)·`Transient`(과도상태) 가드를 통째로 제거 → 라이브 컬럼 수동쓰기에서 **가장 중요한 안전검증을 버리는 HIGH급 회귀**. 진짜 원인은 auto-write/manual-write가 `AdvisoryOnly` 한 플래그에 **결합**된 것 | 🔴 HIGH(오처방) | §3·§5 A-1 = **가드 분리(manualOverride)** | + +### (3) 사실오류·비이슈 — 철회 + +| # | 항목 | 판정 | +|---|------|------| +| D-2 | "warning 4줄 하드코딩" → **사실오류**. 무조건 추가는 **2줄**(L49 steam ceiling, L73 energyLoop)뿐이고 나머지는 조건부. 게다가 이는 버그가 아니라 **미구현 제약을 표시하는 의도적 TODO 마커**. → **진단 철회**(원하면 선택적 정리만, §6 부기) | +| D-10 | "`ex.Message` 감사로그 → 비밀번호/토큰 누출" → **위협 부재**. 이 경로 예외는 Modbus/gRPC 전송오류(host:port)일 뿐, 토큰은 쓰기 호출 전 검증되고 gRPC로 전달되지 않음. 메시지를 가리면 **제어쓰기 실패 원인 추적성만 손상**. → **진단 철회** | + +### (4) "신규 구현(스펙)"을 결함으로 오분류 — 진단 아님, 그냥 작업 항목 + +> 아래는 이 작업지시서가 "만들라고 지시한 것 그 자체"다. 아직 짓지 않은 집의 벽이 없다고 HIGH를 매긴 격 → **진단표에서 제외, 해당 작업 절로 이동.** + +| # | 항목 | 실제 위치 | +|---|------|-----------| +| D-4 | `AutoWriteAsync` L141 `"HC1"` — 코드사실은 맞으나 **이 작업은 `AdvisoryOnly=true` 유지로 AutoWrite 미실행**(死경로). HIGH 아님, 일관성 후속정리 | §6 부기 | +| D-5 | `FeedSpNodeId` 등 DB 컬럼 없음 = **신규 필드 추가 지시**(§6 B-1) | | +| D-6 | MapConfig 신규 필드 노출 = D-5의 귀결(§6 B-1) | | +| D-7 | `ffCard` 쓰기 버튼 없음 = **작업 A 그 자체**(§5 A-2) | | +| D-8 | 배지 "읽기 전용" 변경 = 작업 A 일부(§5 A-2) | | +| D-9 | 램프 실행기/저장소/엔드포인트 없음 = **작업 B 전체**(§6) | | + +--- + +## 0. 목표 (한 줄) + +1. **측류추출 권장 유량(Advisory)** 화면에서 **버튼 클릭 시에만** 해당 스트림의 권장 SP를 실제 HC900 컨트롤러 SP로 쓴다. (자동 쓰기 아님 — 운전원 명시적 인가) +2. **FEED 목표 변경 시** 단번에 점프시키지 않고, **램프 계산기 결과(rate·ceiling)에 따라 FEED SP를 천천히 단계적으로 올린다.** 버튼으로 시작/취소. + +핵심 원칙: **두 기능 모두 "운전원이 버튼을 눌러야만" 실제 쓰기가 시작된다.** 백그라운드 자동쓰기(`AdvisoryOnly=false` 경로)는 이 작업에서 사용하지 않으며 기본값 그대로 둔다. + +--- + +## 1. 현재 코드 자산 (재사용 — 새로 만들지 말 것) + +### 1-A. 측류 SP 수동 쓰기 — **백엔드 이미 존재** + +- `src/Hc900Crawler/Controllers/FeedforwardController.cs` + - `POST api/ff/write/{columnId}/{streamKey}` → `WriteSp(...)` (L78~114) + - 동작: `X-Kb-Token` 인증 → advisory/config/stream 조회 → `SpNodeId` 필수 검사 → `body.value ?? adv.RecommendedSp` → **`_writeGuard.Check(...)`** → `_writeClient.WriteTagAsync("HC1", sc.SpNodeId, spVal)` → 감사로그. + - **요청 바디**: `WriteSpBody { double? value }` (L310). value 비우면 권장값 사용. +- `src/Infrastructure/Control/FeedforwardWriteGuard.cs` — `Check()`: + - Commanded 스트림만 / `AdvisoryOnly`면 차단 / 권장SP 존재 / `Valid` / Grade≠C / `Transient` 아님 / `SpMin~SpMax` 범위 / NaN·Inf 아님. +- `src/Infrastructure/Hc900/Hc900WriteService.cs` — `WriteTagAsync(controllerId, tagName, value)` → gRPC `WriteTag`. +- 감사: `IFeedforwardAuditService.LogAsync(FfActionLogEntry(...))`, 조회 `GET api/ff/audit`. + +> **따라서 측류 SP 쓰기는 "UI 버튼 + 소수 백엔드 보정"만 하면 된다. 새 엔드포인트 불필요.** + +### 1-B. UI (현재 읽기 전용) + +- `src/Hc900Crawler/wwwroot/panes/ff.html` — `

측류추출 권장 유량 설정값 (Advisory · 보조지표)

`, 대시보드 컨테이너 `#ff-dash`. +- `src/Hc900Crawler/wwwroot/js/ff.js` + - `ffLoadDash()` (L145) → `GET /api/ff/advisory` → `ffCard(c)` 렌더. + - `ffCard()` (L165) 스트림 행 렌더 (L166~182): `권장 SP` 칸은 `s.recommendedSp` 표시만, **버튼 없음**. + - 인증 헬퍼: `ffApi(method, path, body)` (L9) — `X-Kb-Token` 자동 첨부, 401 처리. 읽기는 `api(...)` 사용. + - 폴링: `ffInit()`에서 `setInterval(ffLoadDash, 3000)`. +- 람프 계산기 UI: `#ff-ramp` 패널 + `ffRampCompute()` (L42) → `GET /api/ff/ramp-advisor`. **계산 표시만, 실행 없음.** + +### 1-C. 피드 램프 — **계산기만 존재, 실행기 없음** + +- `src/Infrastructure/Control/FeedRampCalculator.cs` — `Compute(...)` 순수함수. 산출: `CurrentFeed`, `ClampedTarget`, `Ceiling(value,binding)`, `RampRate(value=kg/hr·min, binding)`, `RampTimeMin`, `Steam`, `Hold`, `Warnings`. +- `src/Infrastructure/Control/FeedRampAdvisorService.cs` — 라이브값 수집 후 `Compute` 호출 (읽기 전용). +- `src/Hc900Crawler/Controllers/FeedforwardController.cs` `GET api/ff/ramp-advisor` (L154). +- DTO: `src/Core/Application/Feedforward/FeedRampModels.cs` (`FeedRampAdvisory`). +- **없는 것**: ① Feed SP를 쓸 태그(ColumnConfig에 `FeedTag`=PV만 있고 SP 없음) ② 시간에 따라 SP를 단계적으로 써 나가는 상태머신/백그라운드 실행기 ③ 시작/취소 버튼·상태 표시. + +### 1-D. 설정 모델 + +- `src/Core/Application/Feedforward/FeedforwardModels.cs` + - `ColumnConfig`: `FeedTag`(PV), `Streams[]`, `ScanSec`, `StaleSec`, `Enabled`, `AdvisoryOnly`(기본 true) 등. **Feed SP 노드/태그 없음.** + - `StreamConfig`: `SpNodeId`(쓰기 대상 태그명), `SpMin/SpMax`, `RateUpPerMin/RateDnPerMin`, `TargetCoeff`, `Role`. +- 저장: `IFeedforwardConfigStore.SaveColumnAsync/LoadAllAsync` (`FeedforwardConfigStore.cs`). + +### 1-E. 컨트롤러 라우팅(중요) + +- 현재 쓰기는 **`"HC1"` 하드코딩** (FeedforwardController.cs L102, FeedforwardSupervisor.cs L141). +- 멀티컨트롤러 구조: 컨트롤러당 게이트웨이 1개(C1~C4), `ControllerGrpcClientPool.GetClient(controllerId)`로 라우팅. → **컬럼이 어느 컨트롤러에 속하는지** 결정해야 한다 (§4 확인필요 참고). + +--- + +## 2. 범위 + +| # | 작업 | 신규/수정 | +|---|------|-----------| +| A | 측류 권장 SP **쓰기 버튼** (스트림별) | UI 수정 + 백엔드 소폭 | +| B | **FEED 램프 실행기** (시작·단계쓰기·취소·상태) | 신규 서비스 + 설정필드 + 엔드포인트 + UI | + +비범위: 자동 연속제어, 루프 모드 자동전환, OPC UA 복원. + +--- + +## 3. 안전 / 도메인 요구사항 (반드시 준수) + +> 실제 공정 컨트롤러에 쓰는 작업이다. 아래는 협상 불가. + +1. **명시적 인가**: 모든 실쓰기는 버튼 → `confirm()` 다이얼로그 → 1회 실행. 폴링/타이머가 스스로 첫 쓰기를 시작하면 안 됨. (램프는 시작 후 단계 진행은 자동, 그러나 "시작"은 버튼.) +2. **WriteGuard 통과 필수**: 측류 쓰기는 기존 `FeedforwardWriteGuard.Check` 통과한 경우만. 차단 사유는 사용자에게 표시. +3. **범위 클램프**: 모든 쓰기 값은 `[SpMin, SpMax]`(측류) / `[FeedSpMin, FeedSpMax]`(피드) 내로 강제 클램프. 범위 밖이면 쓰기 거부 + 사유 표시. +4. **레이트 리밋**: 동일 대상 최소 간격(측류: 기존 endpoint는 즉시 1회라 OK / 피드 램프: `≥ ScanSec*2`, 권장 5~15s) 미만 재쓰기 금지. +5. **피드 불량 시 HOLD**: Feed PV가 bad/stale/≤0이면 램프 즉시 정지(다음 단계 안 씀), 상태에 HOLD 표시. (`FeedRampCalculator`가 이미 `Hold=true` 반환 — 이를 실행기에서 존중.) +6. **단조 진행**: 업램프 중 계산된 `ClampedTarget`이 ceiling 하락으로 줄어들면, 현재 SP보다 낮은 값으로 갑자기 점프 금지 — 단계 크기는 `RampRate × Δt`로 제한. +7. **모드 전제(반드시 확인)**: HC900 루프가 **외부 SP를 수용하는 모드**가 아니면 SP 쓰기는 무효/무시될 수 있다. (이 저장소 메모리 `mode-write-mechanism`: 루프 mode auto/man·LSP/RSP=CASC는 별도 0xBA/0xBC 레지스터로 씀.) → §4 확인필요. 최소한 쓰기 전 현재 모드를 읽어 경고하거나, 모드 불일치 시 차단할 것. +8. **감사로그**: 측류·피드 모든 실쓰기·차단은 `IFeedforwardAuditService.LogAsync`로 남긴다(operator, value, result, 사유). 쓰기 실패 시 전송오류 메시지는 **그대로 보존**(추적성 — D-10 철회: 이 경로에 토큰/비밀번호는 흐르지 않음). +9. **취소 즉시성**: 램프 취소 버튼은 다음 단계 쓰기를 즉시 멈춘다(현재 SP 유지, 되돌리지 않음). + +### D-1(정정). WriteGuard 결합 분리 — 가드 **유지**, AdvisoryOnly만 분리 + +**증상(사실)**: `FeedforwardWriteGuard.Check` (L12~13)는 `cfg.AdvisoryOnly`가 true면 모든 쓰기를 차단한다. 그런데 이 작업은 auto-write를 막으려 `AdvisoryOnly=true`를 유지하므로 **버튼 수동쓰기까지 같이 막힌다**. 원인은 auto/manual 두 경로가 `AdvisoryOnly` **한 플래그에 결합**된 것. + +**❌ 채택 금지(v2 오처방)**: "버튼 쓰기는 WriteGuard를 우회하고 SpMin/Max·Commanded·SpNodeId만 검증" — 이는 `Valid`(stale/무효 advisory)·`Grade==C`(저신뢰)·`Transient`(과도상태) 가드를 **제거**한다. 사람이 라이브 컬럼에 값을 미는 순간일수록 이 셋이 가장 중요하다(되돌릴 기회 1회). 우회는 §3.2와 정면충돌하는 안전회귀다. + +**✅ 채택(결합 분리)**: 가드는 그대로 두고 `AdvisoryOnly` 의미만 분리한다. 택1: +- (권장) `WriteGuard.Check(cfg, adv, sc, column, bool manualOverride=false)` — `manualOverride==true`일 때 **`AdvisoryOnly` 체크 한 줄만** 건너뛰고 `Valid`/`Grade`/`Transient`/`SpMin~SpMax`/NaN·Inf는 **전부 유지**. `WriteSp`(버튼)만 `manualOverride: true`로 호출. +- (대안) auto-write 게이트를 별도 플래그 `AutoWriteEnabled`로 옮기고 `AdvisoryOnly`는 "자동쓰기 금지·버튼 허용"으로 재정의. + +→ 결과: AdvisoryOnly 컬럼이라도 **버튼은 가능**하되, 과도·저신뢰·stale 상태에서는 여전히 차단된다. + +--- + +## 4. 착수 전 확인 필요 (구현자가 사용자/현장에 질문) + +이 항목들은 값/정책이 코드에 없으므로 **추정 금지, 확정 후 진행**: + +- **C-1. 컨트롤러 ID 소스**: 컬럼→컨트롤러(C1~C4) 매핑을 어디서? (권장: `ColumnConfig.ControllerId` 필드 추가, 기본 `"C1"`. 현재 하드코딩 `"HC1"`이 실제 존재 ID인지 `config/gateway-config.json`과 대조.) +- **C-2. SP 태그 명명**: `SpNodeId`가 OPC UA식(`ns=3;s=ficq-6113.sp`)인지 HC900 태그명(`FICQ-6113.SP`, 레지스터맵 대문자)인지. 게이트웨이는 태그명을 그대로 받으므로 **HC900 태그명**이어야 함. 기존 설정값 점검·정규화 필요. +- **C-3. Feed SP 태그**: FEED 루프의 SP 쓰기 대상 태그명(예: `FICQ-6101.SP`)과 `FeedSpMin/Max`. register-map에서 access=RW 확인. +- **C-4. 루프 모드 전제(§3.7)**: SP 쓰기가 유효하려면 필요한 모드와, 모드 불일치 시 "차단" vs "경고 후 진행" 정책. +- **C-5. 램프 단계 주기/스텝**: 쓰기 간격(기본 10s 제안)과 한 컬럼만/다중 컬럼 동시 램프 허용 여부. + +> 확인 전에는 **DRY-RUN 모드(실쓰기 대신 로그만)**로 개발/검증할 것 (§7). + +--- + +## 5. 작업 A — 측류 권장 SP 쓰기 버튼 + +### A-1. 백엔드 보정 (최소) + +파일: `src/Hc900Crawler/Controllers/FeedforwardController.cs` + +- **D-3 수정**: L102 `WriteTagAsync("HC1", ...)`의 컨트롤러 ID를 **C-1 결정값**으로 교체 (`cfg.ControllerId` 또는 설정). 하드코딩 제거. (현재 `"HC1"`은 매칭 안 돼 쓰기 자체가 실패할 개연성 — 우선순위 높음.) +- **D-1(정정) 적용 — WriteGuard 우회 ❌ / 분리 ✅**: `WriteGuard.Check`에 `manualOverride` 인자를 추가하고 `WriteSp`는 `manualOverride: true`로 호출한다. 이때 **건너뛰는 것은 `AdvisoryOnly` 체크 한 줄뿐**이며 아래는 **반드시 유지**: + - `s.Role == Commanded` + - `adv.Valid` (stale/무효 advisory 차단) + - `adv.Grade != C` (저신뢰 차단) + - `!column.Transient` (과도상태 차단) + - `spVal ∈ [sc.SpMin, sc.SpMax]` (범위밖 차단/클램프) + - `spVal` NaN/Inf 아님 +- 응답에 WriteGuard 차단 사유가 명확히 내려가는지 확인(현재 `BadRequest{error}` OK). +- (선택) 쓰기 전 현재 SP 읽어 응답에 `previousSp` 포함(UI 표시용) — `IHc900GatewayService`/`ListTags`/realtime로 조회. + +> 엔드포인트 시그니처·검증 로직은 그대로 재사용. **새 엔드포인트 만들지 말 것.** + +### A-2. UI — 버튼 추가 + +파일: `src/Hc900Crawler/wwwroot/js/ff.js` (`ffCard` 스트림 행, L166~182) + +- 조건: `s.role === 'Commanded'` **그리고** `s.recommendedSp != null` **그리고** 컬럼이 advisory 유효(`!c.transient`, `s.valid`, `s.grade !== 'C'`)일 때만 행에 버튼 노출. (가드와 동일 조건 — 안 되는 버튼은 비활성/숨김.) +- 버튼: `권장 SP` 칸 옆 또는 신뢰칸에 ``. +- 핸들러 `ffWriteSp(columnId, key, sp)`: + 1. `confirm(\`${key} 스트림 SP를 ${sp} 로 컨트롤러에 쓰시겠습니까?\`)` — 취소 시 중단. + 2. `await ffApi('POST', \`/api/ff/write/${columnId}/${encodeURIComponent(key)}\`, {})` (value 비움 → 서버가 권장값 사용. 또는 `{value: sp}` 명시.) + 3. 성공: 토스트/메시지 표시 후 `ffLoadDash()`로 갱신(기존 `lastWriteSp/lastWriteError`가 표에 반영됨 — L171). + 4. 실패(특히 401): `ffApi`가 던지는 메시지 표시("RAG 관리 탭 로그인 필요" 포함). +- **D-8 수정**: HTML(`ff.html` L3~4) 헤더 배지 문구 "읽기 전용" → "권장값 · **버튼으로 인가 시 쓰기**" 로 수정. + +### A-3. 인증 + +- 쓰기는 `X-Kb-Token` 필요(기존). 토큰 없으면 401 → UI에서 로그인 안내. 버튼을 토큰 보유시에만 활성화할지(`ffToken()` 체크)는 UX 선택. + +### A-4. 수용 기준 (A) + +- [ ] Commanded·유효 스트림에만 "SP 반영" 버튼이 보인다. +- [ ] 클릭 → confirm → 실제 gRPC 쓰기 1회. **AdvisoryOnly 컬럼이라도 버튼 쓰기는 허용**되지만, **Transient/Grade C/Valid 무효/범위밖**이면 차단 메시지(가드 유지). +- [ ] 쓰기 결과가 표에 `쓰기됨/오류`로 표시(`lastWriteSp`). +- [ ] `GET api/ff/audit`에 `sp_write` 항목 기록. +- [ ] 폴링(3s)이 스스로 쓰기를 트리거하지 않음(버튼만). + +--- + +## 6. 작업 B — FEED 램프 실행기 + +### B-1. 설정 모델 확장 + +파일: `src/Core/Application/Feedforward/FeedforwardModels.cs` (`ColumnConfig`) + +- 추가: + - `string? FeedSpNodeId` — FEED SP 쓰기 대상 HC900 태그명 (C-3). null이면 램프 실행 불가. + - `double FeedSpMin`, `double FeedSpMax` — 클램프 한계. + - (선택) `string? ControllerId` — C-1. +- **D-5 수정**: `FeedforwardConfigStore.cs` DB 컬럼 추가 필요: + - `ff_column_config` 테이블에 `feed_sp_node_id`, `feed_sp_min`, `feed_sp_max`, `controller_id` 컬럼 INSERT. + - `LoadAllAsync` SQL에 포함 + 매핑. + - `SaveColumnAsync` INSERT/UPDATE에 포함. +- `FeedforwardController.MapConfig`(L125) 출력에 추가. +- **D-6 수정**: `MapConfig` 익명객체에 `feedSpNodeId`, `feedSpMin`, `feedSpMax`, `controllerId` 필드 추가. +- 설정 UI(`ff.js` 컬럼 에디터, `ffEditColumn`/`g('ff-f-...')` 영역): 위 필드 입력란 추가, `POST api/ff/config` 페이로드(L461 인근)에 포함. + +### B-2. 램프 상태 저장소 (신규) + +신규: `src/Infrastructure/Control/FeedRampJobStore.cs` (+ `Core/Application/Feedforward/IFeedRampStores.cs`에 인터페이스) + +- 컬럼별 활성 램프 1개: `record FeedRampJob { int ColumnId; double TargetFeed; double LastWrittenSp; string State; string? Hold; DateTime StartedAt; DateTime LastStepAt; string Operator; string[] Warnings; }` +- `State`: `Idle | Ramping | Hold | Reached | Canceled`. +- `ConcurrentDictionary`; `Start/Get/GetAll/Cancel/Update`. + +### B-3. 램프 실행 서비스 (신규, BackgroundService) + +신규: `src/Infrastructure/Control/FeedRampExecutorService.cs` — `FeedforwardSupervisor.cs`의 패턴(스코프 생성·주기 루프·rate-limit·감사) 참고. + +- 주기 `Δt`(기본 10s) 루프. 각 활성 Job마다: + 1. `FeedRampAdvisorService.ComputeAsync`로 최신 `CurrentFeed/ClampedTarget/RampRate/Ceiling/Hold/Warnings` 획득. + 2. `Hold==true`(피드 불량) → Job.State=Hold, 쓰기 안 함, 다음 주기. + 3. 목표 도달(`|CurrentFeed - min(TargetFeed,Ceiling)| ≤ ε` 또는 LastWrittenSp가 ClampedTarget 도달) → State=Reached, 쓰기 종료. + 4. **단계 SP 계산**: `step = RampRate(kg/hr·min) × (elapsedMin since LastStepAt)`. `nextSp = clamp(LastWrittenSp + step, .., min(TargetFeed, Ceiling))`. (다운램프는 현재 계산기 미구현 — §3.6/calculator L87 경고; **B에서는 업램프만**, 다운 요청은 거부.) + 5. **가드**: `nextSp`를 `[FeedSpMin, FeedSpMax]` 클램프, NaN/Inf 거부, 직전 쓰기와 간격 `≥ ScanSec*2` 확인. + 6. `Hc900WriteService.WriteTagAsync(controllerId, cfg.FeedSpNodeId, nextSp)` → 성공 시 `LastWrittenSp=nextSp`, `LastStepAt=now`. 감사로그(`action="feed_ramp_write"`). + 7. 실패 → Warnings에 기록, State 유지(다음 주기 재시도, 단 연속 실패 N회 시 Hold). +- **DRY-RUN 플래그**: `appsettings`의 `Feedforward:FeedRampDryRun`(기본 true) — true면 실제 쓰기 대신 로그만. C-1~C-4 확정·현장 합의 후 false. +- `SimOverride` 활성 시 입력이 가짜이므로 **실쓰기 억제**(Supervisor L83·88 패턴과 동일). +- `Program.cs`에 `AddHostedService()` + `FeedRampJobStore` DI 등록. + +### B-4. 엔드포인트 (신규, FeedforwardController) + +- `POST api/ff/feed-ramp/{columnId}/start` body `{ double targetFeed }` — `X-Kb-Token` 필수. 검증: 컬럼 존재·`FeedSpNodeId` 설정·`targetFeed > currentFeed`(업램프)·DryRun 여부 응답. Job 생성(State=Ramping). 즉시 쓰기 금지(다음 실행기 주기에 첫 단계). +- `POST api/ff/feed-ramp/{columnId}/cancel` — Job.State=Canceled, 다음 단계 중단. +- `GET api/ff/feed-ramp/{columnId}` / `GET api/ff/feed-ramp` — 상태 조회(진행률·LastWrittenSp·target·ceiling·hold·warnings·dryRun). + +### B-5. UI — 램프 시작/상태 + +파일: `ff.js` / `ff.html` + +- 램프 계산기 패널(`#ff-ramp`, `ffRampCompute`)에 **"램프 시작"** 버튼 추가: + - 클릭 → `confirm(\`FEED를 ${target}까지 ${rampTimeMin}분에 걸쳐 단계적으로 올립니다. 시작?\`)` → `POST .../start`. + - DryRun이면 버튼 라벨/배지에 "모의(DryRun)" 표기. +- 컬럼 카드(`ffCard`)에 활성 램프 상태 줄 추가: `GET api/ff/feed-ramp` 결과로 `진행 ▶ FEED SP ${LastWrittenSp} → ${target} (ceiling ${ceiling}) [Hold/경고]` + **취소** 버튼. +- 폴링 주기에 램프 상태도 함께 갱신. + +### B-6. 수용 기준 (B) + +- [ ] `FeedSpNodeId` 미설정 컬럼은 램프 시작 불가(명확한 사유). +- [ ] 시작 버튼 → 실행기가 `RampRate` 보폭으로 FEED SP를 단계적으로 증가(단번 점프 없음). 로그/상태에서 단계 확인. +- [ ] Ceiling/SpMax에서 멈춤. `TargetFeed` 도달 시 State=Reached, 쓰기 종료. +- [ ] Feed PV bad/stale → 즉시 Hold, 쓰기 정지. +- [ ] 취소 → 즉시 정지(현재 SP 유지). +- [ ] DryRun=true에서 실제 쓰기 0건(로그만), false 전환 후에만 실쓰기. +- [ ] 모든 단계/차단 감사로그 기록. + +### B-7(부기). D-2 철회 + D-4 후속정리 (선택) + +- **D-2 철회**: "warning 4줄 하드코딩"은 사실오류 — `FeedRampCalculator.Compute`에서 무조건 추가되는 건 **2줄**(L49 steam ceiling, L73 energyLoop)이고, 이는 버그가 아니라 **미구현 제약(스팀·에너지루프 ceiling)을 명시하는 의도적 TODO 마커**다. 본 작업의 필수 항목 아님. 정 거슬리면 UI에서 `미산정` 계열 정보성 문구를 실제 경고와 분리 표시(별도 스타일)하는 정도의 선택적 정리만. +- **D-4 후속정리(선택)**: `FeedforwardSupervisor.AutoWriteAsync` L141의 `"HC1"`도 동일하게 컨트롤러 ID 파라미터화. 단 이 작업은 `AdvisoryOnly=true` 유지로 AutoWrite가 실행되지 않으므로 **死경로** — 본 작업의 차단 요소가 아니며 일관성 차원의 후속 정리. + +--- + +## 7. 테스트 계획 + +1. **단위**: `FeedRampExecutorService` 단계 계산(보폭·클램프·Hold·도달) — `FeedRampCalculator`는 순수함수라 기존 패턴대로 모킹. +2. **시뮬레이터**: `test/modbus_sim.py`(port 5020) + `test/read_tags.py`로 SP 쓰기 반영 확인. 게이트웨이를 sim 대상으로 띄워 gRPC 경로 검증. +3. **SimOverride/DryRun**: 가짜 입력으로 UI·상태머신 흐름 검증(실쓰기 0건 확인). +4. **WriteGuard 경계**: Grade C / Transient / Valid 무효 / 범위밖에서 측류 버튼 **차단** 확인. **AdvisoryOnly 컬럼에서는 (다른 가드 통과 시) 버튼 쓰기 허용**됨을 확인(manualOverride 분리 동작). +5. **실컨트롤러(현장, C-1~C-4 확정 후)**: DryRun=false, 단일 스트림·소폭으로 1회 측류 쓰기 → 실제 SP 변화 확인 → 그 후 피드 램프 소구간 검증. + +--- + +## 8. 산출물 체크리스트 + +- [ ] `ColumnConfig` + 저장소 + 설정 UI: `FeedSpNodeId`, `FeedSpMin/Max`, (`ControllerId`). +- [ ] `FeedRampJobStore` + `IFeedRampStores`. +- [ ] `FeedRampExecutorService`(BackgroundService) + `Program.cs` 등록 + DryRun 설정. +- [ ] FeedforwardController: feed-ramp start/cancel/status 3개 + 측류 쓰기 컨트롤러ID 보정 + **WriteGuard `manualOverride` 분리(우회 아님)**. +- [ ] `WriteGuard.Check` 시그니처에 `manualOverride` 추가 — `Valid`/`Grade`/`Transient`/범위 가드 유지. +- [ ] `ff.js`/`ff.html`: 측류 "SP 반영" 버튼, 램프 "시작/취소/상태" UI, 배지 문구. +- [ ] 감사로그 액션: `sp_write`(기존), `feed_ramp_write`(신규). 실패 메시지 보존(D-10 철회). +- [ ] 단위·sim 테스트. +- [ ] §4 확인필요 항목 모두 확정·문서화. +- [ ] (선택) D-4 AutoWriteAsync 컨트롤러ID 파라미터화. D-2는 철회(필수 아님). + +--- + +## 부록 — 핵심 파일 인덱스 + +| 역할 | 경로 | +|---|---| +| 측류 쓰기 엔드포인트 | `src/Hc900Crawler/Controllers/FeedforwardController.cs` (WriteSp L78) | +| 쓰기 가드 | `src/Infrastructure/Control/FeedforwardWriteGuard.cs` | +| gRPC 쓰기 | `src/Infrastructure/Hc900/Hc900WriteService.cs` / `ControllerGrpcClientPool.cs` | +| 자동쓰기(참고·미사용) | `src/Infrastructure/Control/FeedforwardSupervisor.cs` (AutoWriteAsync L107) | +| 램프 계산기(순수함수) | `src/Infrastructure/Control/FeedRampCalculator.cs` | +| 램프 입력수집 | `src/Infrastructure/Control/FeedRampAdvisorService.cs` | +| 램프 DTO | `src/Core/Application/Feedforward/FeedRampModels.cs` | +| 설정 모델 | `src/Core/Application/Feedforward/FeedforwardModels.cs` | +| 설정 저장소 | `src/Infrastructure/Control/FeedforwardConfigStore.cs` | +| UI | `src/Hc900Crawler/wwwroot/panes/ff.html`, `wwwroot/js/ff.js` | +| 감사 | `IFeedforwardAuditService`, `GET api/ff/audit` | diff --git a/docs/작업플랜-민감단온도-전환복귀제어.md b/docs/작업플랜-민감단온도-전환복귀제어.md new file mode 100644 index 0000000..6be2522 --- /dev/null +++ b/docs/작업플랜-민감단온도-전환복귀제어.md @@ -0,0 +1,93 @@ +# 작업 플랜 — 민감단(T_C) 온도 유지 · 전환류 도피 · Bumpless 복귀 (2026-06-06) + +## 진단 (2026-06-06, diagnosis-checklist.md 적용) + +| # | 심각도 | 항목 | 근거 | 파일·라인 | +|---|--------|------|------|-----------| +| 1 | 🟠 MED | **기존 상태기계와의 통합 방향 미정의** | 계획은 T_C 이탈 중심 트리거를 제안하나, 기존 `FeedforwardEngine.ApplyRecovery()`(FeedforwardEngine.cs:400)는 imbalance(vloss/ΔP/front/tempSevere/sigTHigh) 중심으로 이미 `ColumnMode{Normal,Recovering,Returning}` 전이를 구현함. "enum 그대로 사용"(line 12)이라고만 하고 트리거 통합/대체/병렬 여부가 명시되지 않아 구현 시 양쪽 로직 충돌 위험. 교차검증 Q3: 의도적으로 모호할 수 있으나 상태기계 골격이 이미 가동 중이므로 반드시 정합 필요. | 본문 §0, 대비 `FeedforwardEngine.cs:400-486` | +| 2 | 🟡 LOW | **`TempLowLimit` DB/Config 명세 부재** | "하한 추가"(line 13)만 언급하고 구체적 Config 필드명·DB 컬럼·기본값·트리거 로직을 정의하지 않음. `ColumnConfig.TempHighLimit`(FeedforwardModels.cs:74)은 존재하나 `TempLowLimit`은 아직 없음. `FeedforwardConfigStore.cs:89`의 ordinal 31 이후로 추가 필요. | 본문 §기존자산, 대비 `FeedforwardModels.cs:74` | +| 3 | 🟡 LOW | **복귀(Returning) 게이트 조건 불일치** | 계획(line 38)은 startup 컷인 트리거(reb-A 온도대역+ΔT(A-D) 안정)로 복귀 게이팅을 제안하나, 기존 코드(line 463-464)는 `!severe && frac < ImbalanceTriggerFrac * 0.5`(물질수지 회복)로 평가함. 서로 다른 회복 기준이 동일 상태기계에 적용될 가능성. | 본문 §모듈3, 대비 `FeedforwardEngine.cs:463-464` | +| 4 | 🟡 LOW | **하강 램프 구현 현황과 계획 간 간격** | 계획(line 43)은 "FeedRampAdvisor에 하강 램프 추가(현재 상승만)"라고 명시했으나, `FeedRampCalculator.Compute()`(FeedRampCalculator.cs:87-88)는 `down-ramp` 분기를 `warnings.Add`만 하고 시간 0으로 처리. `StreamConfig.RateDnPerMin`(StreamConfig.cs:27)은 이미 존재하나 계산 로직에 미반영. 구체적 구현 방향(`ComputeAsync` 하강 분기, rate 선택 등)이 계획에 없음. | 본문 §모듈4-A, 대비 `FeedRampCalculator.cs:87-88` | +| 5 | 🟡 LOW | **`tempref` 용어 정의 없음** | line 25, 65에서 `tempref`를 참조하지만 문서 내 정의 없음. 이 문서만 읽으면 `tempref`가 DB 테이블인지, JSON 파일인지, API인지 식별 불가. `c{prefix}_tempref.json`은 `gen_temp_profiles.py`(scripts/analysis) 산출물이며 `SteamAdvisorController.cs:183`에서 로드됨. 동일 디렉토리 `작업플랜-컬럼온도프로파일-이격모니터.md:21`에 정의되어 있으므로 cross-reference 필요. | 본문 line 25, 65 | +| 6 | 🟡 LOW | **파라미터 미지정 (N분, rate, bandwidth)** | "지속 N분"(line 32), "rate-limit 상승/하강"(line 34, 39), "T_C in-band 지속"(line 38)의 구체값·단위·기본값이 명시되지 않음. "hunting-averse"(line 66) 원칙과 배치되는 애매함. 단, 계획 문서의 초기 단계에서는 허용 가능한 수준(교차검증 Q3). | 본문 §모듈2,3,4 | + +### 교차검증 종합 + +- Q1 (이미 수정됨?) — 해당 없음 (plan 문서, 수정 대상 코드 아님) +- Q2 (다른 레이어 처리?) — #1은 기존 FeedforwardEngine이 이미 상태기계를 소유 중이므로 계획이 이를 오버라이드/확장할지 명시해야 함 +- Q3 (의도적 설계?) — #5, #6은 계획 초기 단계의 자연스러운 애매함; #2는 TODO로 의도적이나 명세 누락 +- Q4 (재현 시나리오?) — #1: FeedforwardEngine 활성화 + 이 계획 구현 시 Recovery 트리거 2중 평가로 전환류 진입/복귀 조건 충돌 + +--- +> 온도 프로파일 모니터(`작업플랜-컬럼온도프로파일-이격모니터.md`)의 제어 확장. +> 메모리 §16.9(경량 트림), §17(온도프로파일=품질게이트). SteamAdvisor·FeedRampAdvisor 재활용. + +## 큰 그림 +민감단 **T_C가 곧 품질·분리도 proxy**. 목표: T_C를 목표대역에 유지 → 벗어나면 전환류로 도피 → 회복되면 bumpless 복귀. +상태기계: **Normal → (T_C 이탈) Recovering(전환류) → (T_C 회복) Returning(복귀램프) → Normal**. +★`ColumnMode{Normal,Recovering,Returning}` enum이 이미 FeedforwardModels에 존재 — 이 설계의 골격이 일부 깔려 있음. + +## 기존 자산 연결 (중복금지) +- `ColumnMode`(Normal/Recovering/Returning) — 상태기계 enum 그대로 사용. +- `TempHighLimit`(전환류 트리거, FeedforwardModels) — 상한 트리거 이미 있음. 하한 추가. +- `AdvisoryResult.TempProfileState`(정상/약화/붕괴/역전/정착대기) — 이탈 판정 입력. +- `SteamAdvisor` steam=f(feed,product,T_C) 맵 — **T_C 목표 역산**·다변수 디커플링의 핵심. +- `FeedRampAdvisor`/`FeedRampExecutorService` — 유량 램프(상승). **하강 램프 추가** 필요. +- startup 분석(c6111_startup) 컷인 트리거 reb-A 84.6±0.5℃ & ΔT(A-D) 1.9±0.4℃ — **복귀(Returning) 제품 재컷인 게이트로 재활용**. + +--- + +## 모듈 1 — T_C 유지 SP 제안 (Normal) +**목적**: T_C를 목표대역(예: 기준 median±0.5℃) 유지하는 조작변수 SP 제안. +**물리**: T_C = f(steam↑, feed↓, reflux↓, vacuum). 주 조작 = **리보일러 steam OP**(=SteamAdvisor RecOp). 부하변수(feed/product) 변하면 T_C 흔들림 → steam으로 보상. +**제안 로직**: +1. 기준 T_C target = tempref 매칭제품 median(또는 운전원 설정). +2. **디커플링 feedforward**: feed/product가 바뀌면 SteamAdvisor 맵으로 "그 부하에서 target T_C 유지하는 steam" 산출 → RecOp. (맵이 이미 T_C를 입력으로 학습 → target T_C 고정·부하 대입하면 필요 steam 역산). +3. **경량 트림 FB**(§16.9): 잔차 e=T_C−target에 gain~−0.6, 데드밴드 0.1℃, dwell 20분(hunting-averse). +**산출**: STEAM OP SP 제안(작업지시서-STEAM-SP-FF통합과 연계) + T_C 편차 표시. + +## 모듈 2 — 전환류(Recovering) 전환 트리거 +**목적**: T_C가 목표대역 이탈(분리 악화) → 제품 차단·전량 환류로 도피. +**트리거**: T_C < 하한(또는 프로파일붕괴/역전, TempProfileState) 지속 N분 → Recovering 진입. +**액션**: product SP→0(컷오프), reflux 전량 환류(LINEOUT), steam 유지/소폭. = startup의 "전환류 라인아웃" 상태와 동일. +**주의**: 급변 금지 — product는 rate-limit 하강, steam 급강 금지(컬럼 식음 방지). + +## 모듈 3 — 복귀(Returning) 모드 +**목적**: 전환류 중 T_C가 목표대역 재진입 → 제품 재컷인 후 정상복귀. +**게이트**: ★startup 컷인 트리거 재활용 — reb-A 목표대역 & ΔT(A-D) 안정 & T_C in-band 지속 → 제품 재컷인 허용. +**액션**: product 컷인(rate-limit 상승) → feed 램프업(모듈4) → 안정되면 Normal. + +## 모듈 4 — Bumpless 램프 (핵심) +**목적**: 모드 전환·복귀 시 OP/유량 점프 없이, **민감단 T_C 불변 제약** 하 유량 램프. +**4-A 온도 램프**: FeedRampAdvisor에 **하강 램프 추가**(현재 상승만). reb-A/T_C 목표를 현재값에서 target까지 rate-limit(℃/min) 궤적. bumpless=현재 PV에서 출발. +**4-B 유량 협조 램프(T_C 유지)**: feed(또는 reflux)를 램프할 때 T_C가 흔들리지 않게 **steam을 동반 램프**. + - feed 궤적 f(t) 각 스텝마다 SteamAdvisor 맵으로 "그 feed·target T_C 유지 steam" 산출 → steam OP 동반 궤적. + - 즉 feed↑ 매 스텝에 필요 steam↑를 맵에서 선제 적용(feedforward) → T_C 평탄. + - reflux 램프도 동일(reflux↑→냉각↑→steam 보상). +**4-C Bumpless 보장**: 모드 전환 순간 제안 OP = 현재 실제 OP에서 출발(초기오프셋 0), 이후 ramp. WriteGuard rate-limit으로 점프 차단. + +--- + +## 상태기계 (요약) +``` +Normal ──T_C 이탈(모듈2)──▶ Recovering(전환류) + ▲ │ + │ T_C 회복+컷인게이트(모듈3) + │ ▼ + └────정상 안정──── Returning(복귀 bumpless 램프, 모듈4) +``` +`ColumnMode` enum으로 구현. 각 전이에 dwell·rate-limit·운전원 승인(개별 follow). + +## 안전/주의 +- ★closed-loop는 advisory+개별 follow 기반(메모리: 조건 실변동중 자동제어 금물). 전 모드 advisory-only 우선, 운전원 인가 시 write. +- 전환류/복귀는 안전 시퀀스(급변 금지) — startup/shutdown 분석의 rate 보존. +- T_C target·대역은 tempref 기준 + 운전원 조정. 제품전환(bimodal) 시 target 재설정. +- hunting-averse: 데드밴드·dwell·rate-limit 필수. + +## 검증 +- 오프라인: field_hist에서 실제 전환류 에피소드(startup 펄스) 대비 상태기계 재현. 복귀 컷인 트리거 일치. +- shadow: Normal에서 모듈1 제안 OP vs 실제, T_C 유지 RMSE. +- 램프: feed 램프 구간에서 제안 steam 궤적이 T_C 평탄 유지하는지(맵 예측 vs 실측 T_C). + +## 선행 의존 +온도 프로파일 모니터(완료) · STEAM-SP-FF통합(steam OP 제안 경로) · FeedRampAdvisor 하강램프 확장. diff --git a/docs/작업플랜-컬럼온도프로파일-이격모니터.md b/docs/작업플랜-컬럼온도프로파일-이격모니터.md new file mode 100644 index 0000000..513b04a --- /dev/null +++ b/docs/작업플랜-컬럼온도프로파일-이격모니터.md @@ -0,0 +1,36 @@ +# 작업 플랜 — 컬럼 온도 프로파일 이격 모니터 (2026-06-06) + +> 메모리 §17(온도프로파일=신뢰도/품질 게이트). 차트 라이브러리 ECharts(기보유). + +## 목적 +컬럼 단면 **온도 배열(탑상→리보일러)을 한 차트로** 표시 → 오퍼레이터가 기준 대비 **이격(드리프트)을 한눈에**. +근거: 같은 제품·진공이면 온도 프로파일(reb-A > T_B > T_C > T_D)이 부하무관 불변 → 이격 = 조성/순도 이상 또는 제품전환 신호. + +## 데이터 +- 단별 온도: `reb_temp`(TICA-x111A, 리보일러), `T_B`(TI-x111B), `T_C`(TI-x111C 민감단), `T_D`(TI-x111D 탑상). +- 진공: PICA(프로파일은 진공일정 가정 하 유효 → 진공 동시표시). +- **기준 프로파일**: 안정구간(6-1·9-1의 2~4월) 제품별 클러스터 중앙값 + 밴드(±2σ). 제품 식별=온도프로파일(PM -Fc -Z6 -f C:\field_full.dump + - 비밀번호 물으면 입력. + - DB이름은 SQL Manager Lite 좌측 트리에 보이는 데이터베이스 이름. + - 끝나면 C:\field_full.dump 파일 하나만 전달. + + 2순위 — EMS SQL Manager Lite GUI + + 명령줄이 어려우면 GUI로: + + (A) Backup Database 메뉴가 있으면 (pg_dump 기반): + - 좌측 트리에서 대상 DB 우클릭 → Backup Database... (또는 Services/Tools 메뉴) + - 형식을 Custom / Compressed 선택 → 파일 저장 → .backup 파일 전달 (제가 pg_restore로 복원) + + (B) Backup이 없으면 Extract Database (확실히 존재): + - 메뉴 Database → Extract Database... + - 옵션: Structure + Data 둘 다 체크 → .sql 파일 생성 + - ⚠️ 단 몇 달치 대용량이면 .sql이 수십 GB·매우 느릴 수 있음 → 가능하면 1순위(pg_dump.exe)를 쓰세요. Extract는 + 최후수단. + + 전달할 것 / 알려줄 것 + + - 결과 파일 하나 (field_full.dump / .backup / .sql 무엇이든 OK — 제가 형식 맞춰 복원). + - (가능하면) 파일 대략 용량. + + → 어떤 형식으로 와도 제가 §14.2부터 처리합니다: 커스텀(.dump/.backup)이면 pg_restore, SQL이면 psql로 field_hist DB에 + 복원. + + --- + §14.1에 이 윈도우/EMS 변형을 추가로 기록할까요? (현장이 Windows10 LTSC + SQL Manager Lite로 확정됐으니 §14에 박아두는 + 게 다음에 도움이 됩니다.) \ No newline at end of file diff --git a/scripts/analysis/c6111_extract.py b/scripts/analysis/c6111_extract.py index 47fcc25..faa3fac 100644 --- a/scripts/analysis/c6111_extract.py +++ b/scripts/analysis/c6111_extract.py @@ -134,6 +134,52 @@ def tag_frame(conn, role_map, asset=ASSET): return df.sort_values("dtat").reset_index(drop=True) +_RANGES = None + + +def load_ranges(): + """instrument_ranges.json (gen_instrument_ranges.py 산출) 로드. 없으면 빈 dict.""" + global _RANGES + if _RANGES is None: + import json, os + p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "instrument_ranges.json") + _RANGES = json.load(open(p)) if os.path.exists(p) else {} + if not _RANGES: + print("[warn] instrument_ranges.json 없음 — 계기범위 클린징 건너뜀 " + "(gen_instrument_ranges.py 먼저 실행)", file=sys.stderr) + return _RANGES + + +def clip_to_ranges(df, role_map): + """role 컬럼값이 계기 EU range[lo,hi] 밖이면 NaN(센서 스파이크 제거). + + range는 instrument_ranges.json(realtime 실측 우선, 9·10차는 xlsx)에서 태그별 조회. + OP(밸브%)는 0-100 고정이라 스킵. range 미등록 태그(FIQ/TI signal)도 스킵. + NaN 처리(행 제거 아님) → 다른 role은 유효값 보존, 다운스트림 notna() 필터가 흡수. + """ + import numpy as np + ranges = load_ranges() + if not ranges: + return df + total = 0 + for role, short in role_map.items(): + if role not in df.columns or short.endswith(".OP"): + continue + r = ranges.get(short.split(".")[0]) + if not r: + continue + mask = (df[role] < r["lo"]) | (df[role] > r["hi"]) + n = int(mask.sum()) + if n: + df.loc[mask, role] = np.nan + total += n + print(f" [clip] {role:12s}({short.split('.')[0]}) " + f"range[{r['lo']:.0f},{r['hi']:.0f}] 밖 {n}개 → NaN") + if total: + print(f" [clip] 계기범위 밖 총 {total}개 값 제거") + return df + + def classify_phases(df): """1차 운전모드 분류 (임계 기반, §16.3-2). 추후 정교화.""" import numpy as np diff --git a/scripts/analysis/export_plotdata.py b/scripts/analysis/export_plotdata.py new file mode 100644 index 0000000..6b03c04 --- /dev/null +++ b/scripts/analysis/export_plotdata.py @@ -0,0 +1,404 @@ +""" +Plot data JSON export for web dashboard. + +Usage: + python3 export_plotdata.py --data c61_data.pkl --prefix c61 + python3 export_plotdata.py --data c81_data.pkl --prefix c81 + +Output: data/{prefix}_plotdata.json +""" +import argparse +import json +import os +import sys + +import numpy as np +import pandas as pd + +BASE = os.path.dirname(os.path.abspath(__file__)) +FEATURES = ["feed", "product", "T_C"] +PRODMAP_FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"] +OP_RESAMPLE = "6h" + + +def _load_data(data_path): + df = pd.read_pickle(data_path) + return df.sort_values("dtat").reset_index(drop=True) + + +def _export_prodmap(df, prefix): + """Production map: valve char + operating points + regression.""" + from sklearn.ensemble import GradientBoostingRegressor + from sklearn.linear_model import LinearRegression + from sklearn.metrics import r2_score + from sklearn.model_selection import train_test_split + from sklearn.preprocessing import StandardScaler + + prod = df[df["mode"] == "PROD"].copy() + prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1) + & prod[PRODMAP_FEATURES + ["steam_flow", "steam_op"]].notna().all(axis=1)] + if len(prod) < 50: + return {"warning": "PROD 데이터 부족"} + + valve_char = _valve_char(prod) + ops, gbm, Xte, yte, pred, imp = _regress(prod) + + return { + "valve_char": valve_char, + "operating_points": { + "feed": _safelen(ops["feed"]), + "steam_flow": _safelen(ops["steam_flow"]), + "steam_op": _safelen(ops["steam_op"]), + "n": len(ops), + }, + "pred_vs_actual": { + "actual": _safelen(yte), + "predicted": _safelen(pred), + "r2": round(r2_score(yte, pred), 4), + "n": len(yte), + }, + "feature_importance": { + "feature": [str(f) for f in PRODMAP_FEATURES], + "gbm_importance": [round(float(v), 4) for v in imp.values], + }, + "n_prod_rows": len(prod), + } + + +def _valve_char(df): + """OP(밸브%) ↔ 스팀유량 히스테리시스 특성 (c6111_prodmap.py valve_char() replica).""" + op, fl = df["steam_op"].values, df["steam_flow"].values + dop = np.diff(df["steam_op"].values, prepend=df["steam_op"].values[0]) + up, dn = dop > 0.05, dop < -0.05 + bins = np.arange(np.floor(op.min()), np.ceil(op.max()) + 1, 1.0) + rows = [] + for lo, hi in zip(bins[:-1], bins[1:]): + m = (op >= lo) & (op < hi) + if m.sum() < 20: + continue + fu = float(fl[m & up].mean()) if (m & up).sum() > 5 else None + fd = float(fl[m & dn].mean()) if (m & dn).sum() > 5 else None + rows.append({ + "op": float(lo + .5), + "flow_mean": float(fl[m].mean()), + "flow_up": fu, + "flow_dn": fd, + "n": int(m.sum()), + }) + return rows + + +def _regress(df): + """6h 운전점 집계 → GBM 회귀 (c6111_prodmap.py regress() replica).""" + from sklearn.ensemble import GradientBoostingRegressor + from sklearn.linear_model import LinearRegression + from sklearn.model_selection import train_test_split + from sklearn.preprocessing import StandardScaler + + ops = (df.set_index("dtat").resample(OP_RESAMPLE).median(numeric_only=True) + .dropna(subset=["steam_flow", "feed"])) + ops = ops[ops["feed"] > 50] + X, y = ops[PRODMAP_FEATURES].values, ops["steam_flow"].values + Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.3, random_state=0) + sc = StandardScaler().fit(Xtr) + lin = LinearRegression().fit(sc.transform(Xtr), ytr) + gbm = GradientBoostingRegressor(n_estimators=200, max_depth=2, + learning_rate=0.05, random_state=0).fit(Xtr, ytr) + pred = gbm.predict(Xte) + imp = pd.Series(gbm.feature_importances_, index=PRODMAP_FEATURES) + return ops, gbm, Xte, yte, pred, imp + + +def _export_startup(df, prefix): + """Startup episodes + milestones.""" + cutins = _detect_cutins(df) + if not cutins: + return {"warning": "컷인 이벤트 없음"} + + episodes = [] + milestones_rows = [] + for ci in cutins: + w = df.iloc[max(0, ci - 360):min(len(df), ci + 360)].copy() + w["rel_min"] = (w["dtat"] - df["dtat"].iloc[ci]).dt.total_seconds() / 60 + episodes.append({ + "rel_min": _safelen(w["rel_min"]), + "reb_temp": _safelen(w["reb_temp"]), + "T_D": _safelen(w["T_D"]), + "steam_flow": _safelen(w["steam_flow"]), + "reflux": _safelen(w["reflux"]), + "feed": _safelen(w["feed"]), + "product": _safelen(w["product"]), + "cutin_time": str(df["dtat"].iloc[ci]), + }) + milestones_rows.append(_milestones(df, ci)) + + M = pd.DataFrame(milestones_rows) + return { + "episodes": episodes, + "milestones": { + "steam_to_cutin_min": _nanmid(M["steam_to_cutin"]), + "reflux_to_cutin_min": _nanmid(M["reflux_to_cutin"]), + "cutin_to_full_min": _nanmid(M["cutin_to_full"]), + "cutin_triggers": { + "reb_A": { + "mean": round(float(M["cutin_rebA"].mean()), 1), + "std": round(float(M["cutin_rebA"].std()), 1), + }, + "T_C": { + "mean": round(float(M["cutin_TC"].mean()), 1), + "std": round(float(M["cutin_TC"].std()), 2), + }, + "dT_AD": { + "mean": round(float(M["cutin_dT_AD"].mean()), 1), + "std": round(float(M["cutin_dT_AD"].std()), 1), + }, + }, + }, + "n_episodes": len(episodes), + } + + +def _detect_cutins(df): + """c6111_startup.py detect_cutins() replica.""" + prod = df["product"].values + reb = df["reb_temp"].values + outs = [] + i = 60 + n = len(df) + while i < n: + if prod[i] > 100 and prod[i - 1] <= 100: + pre = prod[max(0, i - 60):i] + if np.nanmedian(pre) < 50 and reb[i] > 75: + outs.append(i) + i += 720 + continue + i += 1 + return outs + + +def _milestones(df, ci): + """c6111_startup.py milestones() replica.""" + tc = df["dtat"].iloc[ci] + back = df.iloc[max(0, ci - 1200):ci] + off = back[back["steam_op"] <= 10] + i_steam = off.index[-1] + 1 if len(off) else back.index[0] + aft = df.iloc[i_steam:ci] + r_on = aft[aft["reflux"] > 100] + i_refl = r_on.index[0] if len(r_on) else None + fwd = df.iloc[ci:ci + 1200] + f_on = fwd[fwd["feed"] > 250] + i_full = f_on.index[0] if len(f_on) else None + + def mins(i): + return None if i is None else (df["dtat"].iloc[i] - tc).total_seconds() / 60 + + r = df.iloc[ci] + return { + "steam_to_cutin": -mins(i_steam) if i_steam is not None else None, + "reflux_to_cutin": -mins(i_refl) if i_refl is not None else None, + "cutin_to_full": mins(i_full) if i_full is not None else None, + "cutin_rebA": float(r["reb_temp"]), + "cutin_TC": float(r["T_C"]), + "cutin_TD": float(r["T_D"]), + "cutin_dT_AD": float(r["reb_temp"] - r["T_D"]), + } + + +def _export_shadow(df, prefix): + """Shadow advisory vs actual OP (6h downsampled) + error histogram.""" + from sklearn.ensemble import GradientBoostingRegressor + + prod = df[df["mode"] == "PROD"].copy() + prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1) + & prod[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat") + if len(prod) < 100: + return {"warning": "PROD 부족 — shadow 불가"} + + for c in FEATURES: + prod[c + "_s"] = prod[c].rolling(40, min_periods=1, center=True).median() + + cut = prod["dtat"].quantile(0.70) + tr, te = prod[prod["dtat"] <= cut], prod[prod["dtat"] > cut] + if len(te) < 50: + return {"warning": "held-out 데이터 부족"} + + ops = (tr.set_index("dtat").resample("6h").median(numeric_only=True) + .dropna(subset=["steam_flow", "feed"])) + ops = ops[ops["feed"] > 50] + model = GradientBoostingRegressor(n_estimators=200, max_depth=2, + learning_rate=0.05, random_state=0) + model.fit(ops[FEATURES].values, ops["steam_flow"].values) + inv = np.polyfit(tr["steam_flow"], tr["steam_op"], 3) + + lo, hi = tr[FEATURES].quantile(0.01), tr[FEATURES].quantile(0.99) + + Xs = te[[c + "_s" for c in FEATURES]].values + pf = model.predict(Xs) + po = np.clip(np.polyval(inv, pf), 0, 100) + ao = te["steam_op"].values + env_mask = ((te[FEATURES] >= lo) & (te[FEATURES] <= hi)).all(axis=1).values + + # 6h downsampled time series for chart + te_plot = te.assign(pred_op=po, pred_flow=pf, ood=~env_mask) + te_plot = te_plot.set_index("dtat") + te_6h = te_plot.resample("6h").agg({ + "steam_op": "mean", "pred_op": "mean", + "steam_flow": "mean", "pred_flow": "mean", + "ood": "max", + }).dropna(subset=["steam_op"]).reset_index() + + errors = po - ao + hist_bins = np.linspace(errors.min(), errors.max(), 61) + hist_counts, hist_edges = np.histogram(errors, bins=hist_bins) + + within_2 = float(np.mean(np.abs(errors) <= 2.0) * 100) + + return { + "time_series": { + "time": [str(t) for t in te_6h["dtat"]], + "actual_op": _safelen(te_6h["steam_op"]), + "predicted_op": _safelen(te_6h["pred_op"]), + "actual_flow": _safelen(te_6h["steam_flow"]), + "predicted_flow": _safelen(te_6h["pred_flow"]), + "ood": [bool(x) for x in te_6h["ood"]], + "n": len(te_6h), + }, + "error_histogram": { + "bin_edges": [round(float(x), 2) for x in hist_edges], + "counts": [int(c) for c in hist_counts], + }, + "summary": { + "n_train": int(len(ops)), + "n_test": int(len(te)), + "mae": float(np.abs(errors).mean()), + "within_2pct": within_2, + "ood_rate": float(np.mean(~env_mask) * 100), + }, + } + + +def _export_operator_assist(df, prefix): + """Operator assist shadow replay (advisory vs actual OP across all PROD).""" + from sklearn.ensemble import GradientBoostingRegressor, IsolationForest + + prod = df[df["mode"] == "PROD"].copy() + prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1) + & prod[FEATURES + ["steam_op"]].notna().all(axis=1)] + if len(prod) < 100: + return {"warning": "PROD 부족 — advisory 불가"} + + points = (prod.set_index("dtat").resample("6h").median(numeric_only=True) + .dropna(subset=["steam_flow", "feed"])) + points = points[points["feed"] > 50] + model = GradientBoostingRegressor(n_estimators=200, max_depth=2, + learning_rate=0.05, random_state=0) + model.fit(points[FEATURES].values, points["steam_flow"].values) + inv = np.polyfit(prod["steam_flow"], prod["steam_op"], 3) + env_lo = points[FEATURES].quantile(0.01) + env_hi = points[FEATURES].quantile(0.99) + ood = IsolationForest(contamination=0.05, random_state=0).fit(points[FEATURES].values) + + X = prod[FEATURES].values + sf = model.predict(X) + op = np.clip(np.polyval(inv, sf), 0, 100) + env_mask = ((X >= env_lo.values) & (X <= env_hi.values)).all(axis=1) + ood_mask = ood.decision_function(X) < 0 + errors = op - prod["steam_op"].values + + # downsampled time series + prod_plot = prod.assign(pred_op=op, pred_flow=sf, ood=ood_mask, in_env=env_mask) + prod_plot = prod_plot.set_index("dtat") + prod_6h = prod_plot.resample("6h").agg({ + "steam_op": "mean", "pred_op": "mean", + "steam_flow": "mean", "pred_flow": "mean", + "ood": "max", "in_env": "min", + }).dropna(subset=["steam_op"]).reset_index() + + hist_bins = np.linspace(errors.min(), errors.max(), 61) + hist_counts, hist_edges = np.histogram(errors, bins=hist_bins) + + return { + "time_series": { + "time": [str(t) for t in prod_6h["dtat"]], + "actual_op": _safelen(prod_6h["steam_op"]), + "predicted_op": _safelen(prod_6h["pred_op"]), + "actual_flow": _safelen(prod_6h["steam_flow"]), + "predicted_flow": _safelen(prod_6h["pred_flow"]), + "ood": [bool(x) for x in prod_6h["ood"]], + "in_env": [bool(x) for x in prod_6h["in_env"]], + "n": len(prod_6h), + }, + "error_histogram": { + "bin_edges": [round(float(x), 2) for x in hist_edges], + "counts": [int(c) for c in hist_counts], + }, + "summary": { + "n_operating_points": len(points), + "n_prod_rows": len(prod), + "mae": float(np.abs(errors).mean()), + "within_2pct": float(np.mean(np.abs(errors) <= 2.0) * 100), + "ood_rate": float(np.mean(ood_mask) * 100), + }, + } + + +def _safelen(x): + """Convert pd.Series/np.array to Python list, handling NaNs.""" + if hasattr(x, "tolist"): + return [None if (isinstance(v, float) and np.isnan(v)) else v for v in x.tolist()] + if isinstance(x, np.ndarray): + return [None if (isinstance(v, float) and np.isnan(v)) else v for v in x.tolist()] + return list(x) + + +def _nanmid(s): + """Median of series, returning None if empty.""" + v = s.dropna() + return round(float(v.median()), 1) if len(v) else None + + +def main(): + parser = argparse.ArgumentParser(description="Export plot data as JSON for web dashboard") + parser.add_argument("--data", default=os.path.join(BASE, "c6111_data.pkl")) + parser.add_argument("--prefix", default="c6111") + parser.add_argument("--output", default=None, help="Output path (default: data/{prefix}_plotdata.json)") + args = parser.parse_args() + + df = _load_data(args.data) + prefix = args.prefix + out_path = args.output or os.path.join(BASE, f"{prefix}_plotdata.json") + + result = { + "prefix": prefix, + "n_total_rows": len(df), + "date_range": [str(df["dtat"].min()), str(df["dtat"].max())], + } + + result["prodmap"] = _export_prodmap(df, prefix) + print(f"[export] prodmap: {result['prodmap'].get('n_prod_rows', 'N/A')} PROD rows") + + result["startup"] = _export_startup(df, prefix) + print(f"[export] startup: {result['startup'].get('n_episodes', 'N/A')} episodes") + + result["shadow"] = _export_shadow(df, prefix) + s = result["shadow"].get("summary") + if s: + print(f"[export] shadow: MAE={s['mae']:.2f} within2%={s['within_2pct']:.1f}%") + else: + print(f"[export] shadow: {result['shadow'].get('warning', 'N/A')}") + + result["advisory"] = _export_operator_assist(df, prefix) + s = result["advisory"].get("summary") + if s: + print(f"[export] advisory: MAE={s['mae']:.2f} within2%={s['within_2pct']:.1f}%") + else: + print(f"[export] advisory: {result['advisory'].get('warning', 'N/A')}") + + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, ensure_ascii=False, default=str) + print(f"[export] 저장: {out_path} ({os.path.getsize(out_path) / 1024:.0f} KB)") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/gen_instrument_ranges.py b/scripts/analysis/gen_instrument_ranges.py new file mode 100644 index 0000000..756dcf5 --- /dev/null +++ b/scripts/analysis/gen_instrument_ranges.py @@ -0,0 +1,85 @@ +""" +계기 EU range 룩업 생성 → instrument_ranges.json (오프라인 분석 전용). + +소스 우선순위 (사용자 확정 2026-06-06): + 1) realtime_table PV_HighRange/PV_LowRange — 컨트롤러 실측값(권위). online 컬럼(5·6·8차). + 2) xlsx InstructionsDisplayNumber — Experion 포인트 설정. online 안 된 컬럼(9·10차=C4) fallback. + +★ 이 JSON은 오프라인 분석(prodmap/export_model 등)의 스파이크 클린징 입력 전용. + C# 운영 코드(SteamAdvisor)는 절대 이걸 안 씀 — realtime range를 live로 직접 읽음. + +교차검증(2026-06-06): realtime vs xlsx 23/32 일치. 8차는 xlsx가 미동기(1000 vs 실측 18000) + → online 컬럼은 realtime이 권위, xlsx는 미연결 컬럼만. +""" +import json +import os +import openpyxl +import psycopg + +BASE = os.path.dirname(os.path.abspath(__file__)) +XLSX = os.path.join(BASE, "..", "..", "docs", "Sinam_Tag_all.xlsx") +OUT = os.path.join(BASE, "instrument_ranges.json") +DSN_IIOT = "host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres" +DEFAULT_LO = -5.0 # xlsx fallback의 LowRange (realtime 실측 대부분 -5~0) + + +def realtime_ranges(): + """5·6·8차 등 online 컬럼의 실측 EU range (권위).""" + out = {} + with psycopg.connect(DSN_IIOT) as c: + cur = c.cursor() + cur.execute(""" + SELECT split_part(tagname,'.',1) AS base, + max(CASE WHEN tagname LIKE '%PV_HighRange' THEN livevalue::float END) AS hi, + max(CASE WHEN tagname LIKE '%PV_LowRange' THEN livevalue::float END) AS lo + FROM hc900.realtime_table + WHERE tagname LIKE '%PV_%Range' + GROUP BY 1 + """) + for base, hi, lo in cur.fetchall(): + if hi is not None: + out[base] = {"lo": lo if lo is not None else DEFAULT_LO, + "hi": hi, "src": "realtime"} + return out + + +def xlsx_ranges(): + """InstructionsDisplayNumber = range hi (Experion 포인트 설정).""" + wb = openpyxl.load_workbook(XLSX, read_only=True, data_only=True) + ws = wb["Sheet1"] + rows = ws.iter_rows(values_only=True) + next(rows) # 머지셀 None 행 + header = list(next(rows)) # 실제 헤더 + ni = header.index("ItemName") + idn = header.index("InstructionsDisplayNumber") + out = {} + for r in rows: + nm, v = r[ni], r[idn] + if not nm or not isinstance(v, (int, float)) or isinstance(v, bool): + continue # 숫자(range hi)만 — 'default' 등 문자열 스킵 + base = nm.split(".")[0] + if base not in out: # 첫 숫자값 채택 + out[base] = float(v) + return out + + +def main(): + rt = realtime_ranges() + xl = xlsx_ranges() + ranges = dict(rt) # realtime 우선 + n_xlsx = 0 + for base, hi in xl.items(): + if base not in ranges: # online 안 된 컬럼만 xlsx fallback + ranges[base] = {"lo": DEFAULT_LO, "hi": hi, "src": "xlsx"} + n_xlsx += 1 + with open(OUT, "w") as f: + json.dump(ranges, f, indent=2, sort_keys=True) + print(f"realtime(실측) {len(rt)}개 + xlsx(fallback) {n_xlsx}개 = 총 {len(ranges)}개 태그") + print(f"저장: {OUT}") + # 9·10차 샘플 확인 + for b in ["FICQ-9201", "FICQ-9218", "FICQ-10201", "FICQ-10218"]: + print(f" {b}: {ranges.get(b)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/gen_temp_profiles.py b/scripts/analysis/gen_temp_profiles.py new file mode 100644 index 0000000..e2f0eb3 --- /dev/null +++ b/scripts/analysis/gen_temp_profiles.py @@ -0,0 +1,99 @@ +""" +컬럼 온도 프로파일 기준 산출 → c{prefix}_tempref.json (이격 모니터 레이어①). + +근거: 메모리 §17 — 같은 제품·진공이면 단별 온도(reb-A>T_B>T_C>T_D)가 부하무관 불변. + 제품 식별 = 온도프로파일(reb_temp 클러스터). 진공 종속이라 vacuum 기준 동시 산출. + +안정구간 PROD에서 제품별 단별 median/σ + 진공 median → 기준밴드(±2σ). +오퍼레이터 이격 모니터/품질 게이트의 reference. +""" +import argparse +import json +import os +import numpy as np +import pandas as pd +from sklearn.cluster import KMeans + +BASE = os.path.dirname(os.path.abspath(__file__)) +STAGES = ["reb_temp", "T_B", "T_C", "T_D"] # 보텀(리보일러) → 탑상 +PROD_MIN = 50 # feed 하한 +MIN_CLUSTER = 0.05 # 클러스터 최소 비중(5%) + + +def cluster_products(reb): + """reb_temp 1D 클러스터 = 제품 식별. 인접 중심차 <2℃면 병합(과분할 방지).""" + reb = reb.reshape(-1, 1) + for k in (3, 2, 1): + km = KMeans(k, n_init=10, random_state=0).fit(reb) + cen = np.sort(km.cluster_centers_.ravel()) + sizes = np.bincount(km.labels_, minlength=k) / len(reb) + if k == 1: + return km + if all(cen[i+1]-cen[i] >= 2.0 for i in range(k-1)) and sizes.min() >= MIN_CLUSTER: + return km + return KMeans(1, n_init=10, random_state=0).fit(reb) + + +def build(prefix, stable_from=None, stable_to=None): + pkl = os.path.join(BASE, f"c{prefix}_data.pkl") + if prefix == "61" and not os.path.exists(pkl): + pkl = os.path.join(BASE, "c6111_data.pkl") + if not os.path.exists(pkl): + print(f" [skip] {prefix}: {pkl} 없음") + return None + df = pd.read_pickle(pkl) + df = df[df["mode"] == "PROD"].copy() + df = df[(df["feed"] > PROD_MIN) & df[STAGES + ["vacuum"]].notna().all(axis=1)] + if stable_from: + df = df[df["dtat"] >= pd.Timestamp(stable_from)] + if stable_to: + df = df[df["dtat"] <= pd.Timestamp(stable_to)] + if len(df) < 200: + print(f" [skip] {prefix}: 안정 PROD 부족 ({len(df)}행)") + return None + + km = cluster_products(df["reb_temp"].values) + df["prod"] = km.labels_ + order = df.groupby("prod")["reb_temp"].median().sort_values().index # 저온→고온 + products = [] + for rank, pid in enumerate(order): + g = df[df["prod"] == pid] + stages = {s: {"median": round(float(g[s].median()), 2), + "std": round(float(g[s].std()), 2)} for s in STAGES} + products.append({ + "label": f"P{rank}", # 추후 PM/PGMEA/EL 등 화학 라벨 매핑 + "n_rows": len(g), + "span_AD": round(float((g["reb_temp"] - g["T_D"]).median()), 2), + "vacuum": {"median": round(float(g["vacuum"].median()), 2), + "std": round(float(g["vacuum"].std()), 2)}, + "stages": stages, + }) + ref = {"column": f"c{prefix}", "stages_order": STAGES, + "n_products": len(products), + "period": f"{df['dtat'].min():%Y-%m-%d}~{df['dtat'].max():%Y-%m-%d}", + "products": products} + out = os.path.join(BASE, f"c{prefix}_tempref.json") + with open(out, "w") as f: + json.dump(ref, f, indent=2, ensure_ascii=False) + print(f" c{prefix}: 제품 {len(products)}개 ", end="") + for p in products: + s = p["stages"] + print(f"[{p['label']} reb{s['reb_temp']['median']:.1f}/Tc{s['T_C']['median']:.1f}/" + f"Td{s['T_D']['median']:.1f} vac{p['vacuum']['median']:.0f} n{p['n_rows']}]", end=" ") + print(f"→ {os.path.basename(out)}") + return ref + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--prefix", help="단일 컬럼 (생략시 전체)") + ap.add_argument("--from", dest="stable_from", help="안정구간 시작 YYYY-MM-DD") + ap.add_argument("--to", dest="stable_to", help="안정구간 끝") + args = ap.parse_args() + prefixes = [args.prefix] if args.prefix else ["61", "62", "81", "91", "92", "101", "102"] + for p in prefixes: + build(p, args.stable_from, args.stable_to) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/instrument_ranges.json b/scripts/analysis/instrument_ranges.json new file mode 100644 index 0000000..83b3eaa --- /dev/null +++ b/scripts/analysis/instrument_ranges.json @@ -0,0 +1,2722 @@ +{ + "AIA-131": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "AIC-131": { + "hi": 20.0, + "lo": -5.0, + "src": "xlsx" + }, + "AQ_LAYER_DENSITY": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "BOILER_PRESS2": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "CMA-2811": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "CT-360": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-5320A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-5320B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6120A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6120B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6127A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6127B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6220A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6220B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6227A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-6227B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-8120A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-8120B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-8120C": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-8120D": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-9120A": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "DP-9120B": { + "hi": 10000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FCV111_AL_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-121": { + "hi": 2500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-124": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2111": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2113": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2123": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2124": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2131": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2132": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2202": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2203": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-2810": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-3203": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-3208": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-3401": { + "hi": 7500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-3402": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5101": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5113": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5114": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5115": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5116": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5118": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-5320": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6101": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6113": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6114": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6115": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6116": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6118": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6120": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6128": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6201": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6213": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6214": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6215": { + "hi": 400.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6216": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6218": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-6220": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8101": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8113": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8114": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8115": { + "hi": 550.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8116": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FI-8118": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIC-2131": { + "hi": 2500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICA-3102_OP": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICA-3203": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10101": { + "hi": 3500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10113": { + "hi": 5000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10114A": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10116": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10118": { + "hi": 3500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10201": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10213": { + "hi": 3500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10214": { + "hi": 300.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10216": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-10218": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-111": { + "hi": 2500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-113": { + "hi": 1200.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-122": { + "hi": 3500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-124": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-131": { + "hi": 700.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-132": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2111": { + "hi": 5000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2113": { + "hi": 2400.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2122": { + "hi": 6500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2123": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2124": { + "hi": 4000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-2132": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-3101": { + "hi": 18000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-5101": { + "hi": 200.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-5113": { + "hi": 240.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-5114": { + "hi": 240.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-5116": { + "hi": 100.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-5118": { + "hi": 200.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6101": { + "hi": 2000.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6113": { + "hi": 3000.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6114": { + "hi": 1000.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-6116": { + "hi": 500.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6118": { + "hi": 2000.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6201": { + "hi": 1000.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6213": { + "hi": 2000.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-6214": { + "hi": 700.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-6216": { + "hi": 400.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-6218": { + "hi": 1000.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-8101": { + "hi": 18000.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-8113": { + "hi": 18000.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-8114": { + "hi": 5000.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-8116": { + "hi": 300.0, + "lo": 0.0, + "src": "realtime" + }, + "FICQ-8118": { + "hi": 760.0, + "lo": -5.0, + "src": "realtime" + }, + "FICQ-9101": { + "hi": 3000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9113": { + "hi": 3600.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9114": { + "hi": 800.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9116": { + "hi": 300.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9118": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9201": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9213": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9214": { + "hi": 400.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9216": { + "hi": 400.0, + "lo": -5.0, + "src": "xlsx" + }, + "FICQ-9218": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-10115": { + "hi": 3000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-10215": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-114": { + "hi": 300.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-115": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-116": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-123": { + "hi": 1200.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-125": { + "hi": 1200.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2114": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2115": { + "hi": 2500.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2116": { + "hi": 300.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2121": { + "hi": 5000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2125": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-2201": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-3101_EVE": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-3102_EVE": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-3208B": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-5115": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-5320": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6115": { + "hi": 800.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6120": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6127": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6215": { + "hi": 800.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6220": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-6227": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-7128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-8115": { + "hi": 550.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-9100": { + "hi": 3000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-9115": { + "hi": 2000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-9120": { + "hi": 20000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FIQ-9215": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-111_1": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-111_2": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-111_3": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-111_4": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-114": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-123": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_1": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_2": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_3": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_4": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_5": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-124_SEL": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2111_1": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2111_2": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2111_3": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2111_4": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2116": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2121": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2123": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2124": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2124_1": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2124_2": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2124_3": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2131": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2132": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2135A": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2135B": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2201": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2202": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2203": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-2810": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3101": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3102_DR_TOT": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3203": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3203_DR_TOT": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3208_DR_TOT": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3208_L_SET": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3208_TOT": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3401": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-3402": { + "hi": 999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9113": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9114": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9116": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9118": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9201": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9213": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9214": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9215": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9216": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "FQ-9218": { + "hi": 9999999.0, + "lo": -5.0, + "src": "xlsx" + }, + "HTR_SW_TEMP": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "HT_LOW": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "HT_SP": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "IA_PRESS2": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10100": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10200": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10201": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10221": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-10800": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2102": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2103": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2113A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2113B": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2128A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2128B": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2135A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2135B": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2201": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2202": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2203": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2205": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2501": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2502": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2704": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2705": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2803": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2805": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2810": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-2950": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3101_LL_SET": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3202": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3203": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3206": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3207": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3207_LL_SET": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3208": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3210": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3211": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3470": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-3705": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-5111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-5113": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-5113B": { + "hi": 110.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-5321": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-5322": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6100": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6113": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6122": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6123": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6124": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6125": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6126": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6128_LL_SET": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6200": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6211": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6211SP": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6211_LL_SET": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6213": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6221": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6222": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6223": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6224": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6225": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-6226": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-702": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-703": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-7128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-8111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-8121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-8122": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9100": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9122": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9123": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9124": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9125": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9200": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9201": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9211": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LI-9221": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-10111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-101A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-102": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-10211": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-103": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-104": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-128": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-201": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-202": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-203": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-204": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-205": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-206": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-301": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-502": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-803": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIA-805": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIC-111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIC-121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-10113": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-10213": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-113": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-123": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-131": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2111": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2113A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2123": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2131": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-2705": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-3403": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-3705": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-5113": { + "hi": 100.0, + "lo": 0.0, + "src": "realtime" + }, + "LICA-6113": { + "hi": 100.0, + "lo": -5.0, + "src": "realtime" + }, + "LICA-6128": { + "hi": 100.0, + "lo": -5.0, + "src": "realtime" + }, + "LICA-6213": { + "hi": 100.0, + "lo": 0.0, + "src": "realtime" + }, + "LICA-705": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-8113": { + "hi": 100.0, + "lo": -5.0, + "src": "realtime" + }, + "LICA-9113": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LICA-9213": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIS-2402": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIS-402": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LIS-701": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LISA-2401": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "LISA-401": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "LISA-501": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "M131_IL_HISTERISIS": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "M131_IL_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "N2_PRESS2": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "OIL_LAYER_DENSIT": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3205B_IFB": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3205B_RPM": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3206A_IFB": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3206A_RPM": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3206B_IFB": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "P-3206B_RPM": { + "hi": 60.0, + "lo": -5.0, + "src": "xlsx" + }, + "PCV8111A": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "PHA2813": { + "hi": 14.0, + "lo": -5.0, + "src": "xlsx" + }, + "PH_HH_SET": { + "hi": 14.0, + "lo": -5.0, + "src": "xlsx" + }, + "PH_LL_SET": { + "hi": 14.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10111B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10211B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10700": { + "hi": 15.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900": { + "hi": 10.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900E": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900F": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900G": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900H": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10900I": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-10952": { + "hi": 10.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-2111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-2121": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-2700": { + "hi": 15.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-2900": { + "hi": 10.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-2952": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-3203": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-5111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-5111B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6111B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6211": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6211B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6700": { + "hi": 15.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6900": { + "hi": 10.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6903A": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6903B": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6903C": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6950": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6950B": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-6950C": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-8111A": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-8111B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-9111B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-9211B": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-952A": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-952B": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PI-952C": { + "hi": 1000.0, + "lo": -5.0, + "src": "xlsx" + }, + "PIA-700": { + "hi": 15.0, + "lo": -5.0, + "src": "xlsx" + }, + "PIA-900": { + "hi": 10.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-10111A": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-10211A": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-121": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-2111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-2121": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-3203": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-5111": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-6111": { + "hi": 760.0, + "lo": -5.0, + "src": "realtime" + }, + "PICA-6211": { + "hi": 760.0, + "lo": -5.0, + "src": "realtime" + }, + "PICA-8111A": { + "hi": 760.0, + "lo": 0.0, + "src": "realtime" + }, + "PICA-9111A": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PICA-9211A": { + "hi": 760.0, + "lo": -5.0, + "src": "xlsx" + }, + "PISA-952_LOW": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "POIANA1": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "RECIPE_SEL": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "RMA2812": { + "hi": 20.0, + "lo": -5.0, + "src": "xlsx" + }, + "SWTEMP_H_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "SWTEMP_L_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TE_OUT": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10103": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10111B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10111C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10111D": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10117": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-101A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10203": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10211B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10211C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10211D": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10217": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-103": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10600": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-10650": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-111A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-111B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-111C": { + "hi": 500.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-121B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-131": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-201": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-202": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-203": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-204": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2103": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2111A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2111B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2111C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2121B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2121C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2131": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2203": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2205": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2600": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-2650": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3101": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3102A_HH_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3102A_LL_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3102B_HH_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3102B_LL_SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3202A": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3202B": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3202C": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3203": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3208": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3208B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3470": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3600": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3650": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3701": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-3702": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5103": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5111A": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5111B": { + "hi": 135.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5111D": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5117": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5321": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5322": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-5601": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6103": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6111A": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6111B": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6111C": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6111D": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6117": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6121": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6122": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6123": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6125": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6126": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6203": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6211A": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6211B": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6211C": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6211D": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6217": { + "hi": 120.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6221": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6222": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6223": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6225": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6226": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-6601": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8103": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8111A": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8111B": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8111C": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8111D": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8117": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8117HSET": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8121": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8122": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8600": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8601": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-8650": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9103": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9111B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9111C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9111D": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9117": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9125": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9203": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9211B": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9211C": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9211D": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9217": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9600": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TI-9650": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TIA-121A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TIA-121A-IL-SET": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TIA-2121A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TIA-601": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-10111A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-10211A": { + "hi": 100.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-111": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-111_LSP": { + "hi": 9999.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-2111": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-3202A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-3403": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-5111A": { + "hi": 200.0, + "lo": -5.0, + "src": "realtime" + }, + "TICA-6111A": { + "hi": 500.0, + "lo": -5.0, + "src": "realtime" + }, + "TICA-6211A": { + "hi": 500.0, + "lo": -5.0, + "src": "realtime" + }, + "TICA-8111A": { + "hi": 200.0, + "lo": 0.0, + "src": "realtime" + }, + "TICA-9111A": { + "hi": 400.0, + "lo": -5.0, + "src": "xlsx" + }, + "TICA-9211A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "TRA-2111A": { + "hi": 200.0, + "lo": -5.0, + "src": "xlsx" + }, + "WT-DFU1": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + }, + "WT-DFU2": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + }, + "WT-DFU3": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + }, + "WT-DFU4": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + }, + "WT-DFU5": { + "hi": 1500.0, + "lo": -5.0, + "src": "xlsx" + } +} \ No newline at end of file diff --git a/scripts/analysis/run_column.py b/scripts/analysis/run_column.py index ea618cc..cdd202b 100644 --- a/scripts/analysis/run_column.py +++ b/scripts/analysis/run_column.py @@ -37,7 +37,7 @@ PY = sys.executable def extract(prefix, asset): """추출 + 운전모드 분류. c{prefix}_data.pkl 저장.""" - from c6111_extract import roles_for, tag_frame, classify_phases + from c6111_extract import roles_for, tag_frame, classify_phases, clip_to_ranges with psycopg.connect(DSN) as conn: roles = roles_for(prefix, asset) @@ -46,6 +46,7 @@ def extract(prefix, asset): print(f" {k:15s} -> {v}") df = tag_frame(conn, roles, asset) + df = clip_to_ranges(df, roles) # 계기 EU range 밖 스파이크 → NaN df["mode"] = classify_phases(df) out = os.path.join(BASE, f"c{prefix}_data.pkl") df.to_pickle(out) diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 9f9d353..ba980d2 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -38,8 +38,13 @@ public sealed record ColumnConfig public string Name { get; init; } = ""; public bool Enabled { get; init; } public bool AdvisoryOnly { get; init; } = true; + public string ControllerId { get; init; } = "C1"; // SP 쓰기 대상 컨트롤러(C1~C4). 기존 "HC1" 하드코딩 대체 public string FeedTag { get; init; } = ""; public string? PressureTag { get; init; } + // 작업 B: FEED 램프 실행 대상 + public string? FeedSpNodeId { get; init; } // FEED SP 쓰기 대상 HC900 태그명(null=램프 불가) + public double FeedSpMin { get; init; } = 0.0; // FEED SP 클램프 하한 + public double FeedSpMax { get; init; } = double.MaxValue; // FEED SP 클램프 상한 public IReadOnlyList LevelTags { get; init; } = Array.Empty(); public double ScanSec { get; init; } = 2.0; public double FeedFilterTauSec { get; init; } = 300.0; @@ -67,6 +72,14 @@ public sealed record ColumnConfig public string? DeltaPTag { get; init; } public double DeltaPFloodLimit { get; init; } = double.MaxValue; public double TempHighLimit { get; init; } = double.MaxValue; // 온도 HIGH LIMIT(raw) — 전환류 트리거. UI 설정. 1e9=비활성 + // 작업플랜-민감단온도: T_C 하한 트리거 + 복귀 게이트 온도 기준 + public double TempLowLimit { get; init; } = -double.MaxValue; // T_C LOW LIMIT — 전환류 트리거. -1e9=비활성 + public double TcReturnRebTarget { get; init; } = double.NaN; // 복귀 게이트 reb-A 목표온도(℃). NaN=비활성 + public double TcReturnRebBand { get; init; } = 0.5; // reb-A 목표대역 반폭(℃) + public double TcReturnDeltaAdRef { get; init; } = double.NaN; // 복귀 게이트 ΔT(A-D) 기준(℃). NaN=비활성 + public double TcReturnDeltaAdBand { get; init; } = 0.4; // ΔT(A-D) 안정대역 반폭(℃) + public double TcReturnTcTarget { get; init; } = double.NaN; // 복귀 게이트 T_C(민감단) 목표온도(℃). reb-A와 다름. NaN=T_C 게이트 비활성 + public double TcReturnTcBand { get; init; } = 1.0; // T_C 목표대역 반폭(℃) } public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp); diff --git a/src/Core/Application/Feedforward/FfSpTag.cs b/src/Core/Application/Feedforward/FfSpTag.cs new file mode 100644 index 0000000..4e958ca --- /dev/null +++ b/src/Core/Application/Feedforward/FfSpTag.cs @@ -0,0 +1,20 @@ +namespace Hc900Crawler.Core.Application.Feedforward; + +/// +/// 스트림/피드의 flow 태그(예: FICQ-6118, ficq-6118.pv)에서 +/// 루프 Working Set Point 태그(.SP, register-map RW)를 파생한다. +/// override 태그가 주어지면 그것을 우선(선택적 수동 지정). +/// +public static class FfSpTag +{ + public static string? Resolve(string? baseTag, string? overrideTag = null) + { + if (!string.IsNullOrWhiteSpace(overrideTag)) return overrideTag.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(baseTag)) return null; + + var t = baseTag.Trim(); + if (t.EndsWith(".pv", StringComparison.OrdinalIgnoreCase)) t = t[..^3]; + if (t.EndsWith(".sp", StringComparison.OrdinalIgnoreCase)) return t.ToUpperInvariant(); + return (t + ".SP").ToUpperInvariant(); + } +} diff --git a/src/Core/Application/Feedforward/IFeedRampStores.cs b/src/Core/Application/Feedforward/IFeedRampStores.cs new file mode 100644 index 0000000..62f1070 --- /dev/null +++ b/src/Core/Application/Feedforward/IFeedRampStores.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace Hc900Crawler.Core.Application.Feedforward; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FeedRampState { Ramping, Hold, Reached, Canceled } + +/// +/// 작업 B: FEED 램프 실행 작업 상태. 컬럼당 1개. FeedRampExecutorService가 단계마다 갱신. +/// LastWrittenSp = 마지막으로 컨트롤러에 쓴(또는 DryRun에서 쓸 예정이던) FEED SP. +/// +public sealed record FeedRampJob +{ + public int ColumnId { get; init; } + public double TargetFeed { get; init; } + public double LastWrittenSp { get; init; } = double.NaN; // NaN=아직 첫 단계 전(앵커 미설정) + public FeedRampState State { get; init; } = FeedRampState.Ramping; + public string? Hold { get; init; } // Hold 사유(피드 불량 등) + public double? CurrentFeed { get; init; } + public double? Ceiling { get; init; } + public double? RampRate { get; init; } // kg/hr·min + public DateTime StartedAt { get; init; } + public DateTime? LastStepAt { get; init; } + public string Operator { get; init; } = "manual"; + public bool DryRun { get; init; } + public IReadOnlyList Warnings { get; init; } = Array.Empty(); +} + +public interface IFeedRampJobStore +{ + FeedRampJob Start(int columnId, double targetFeed, string op, bool dryRun); + FeedRampJob? Get(int columnId); + IReadOnlyCollection GetAll(); + void Update(FeedRampJob job); + bool Cancel(int columnId); +} diff --git a/src/Core/Application/Feedforward/IFeedforwardStores.cs b/src/Core/Application/Feedforward/IFeedforwardStores.cs index be2ad7e..3e900bb 100644 --- a/src/Core/Application/Feedforward/IFeedforwardStores.cs +++ b/src/Core/Application/Feedforward/IFeedforwardStores.cs @@ -19,7 +19,9 @@ public sealed record WriteCheckResult(bool Allowed, string? Reason); public interface IFeedforwardWriteGuard { - WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column); + // manualOverride=true: 운전원 버튼 쓰기 — AdvisoryOnly 한 줄만 건너뛰고 + // Valid/Grade/Transient/범위/NaN 가드는 그대로 유지(D-1 정정: 우회 아님, 결합 분리). + WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column, bool manualOverride = false); } // Phase II: 감사 로그 서비스 diff --git a/src/Core/Application/Feedforward/IFfTrackingStore.cs b/src/Core/Application/Feedforward/IFfTrackingStore.cs new file mode 100644 index 0000000..6c75602 --- /dev/null +++ b/src/Core/Application/Feedforward/IFfTrackingStore.cs @@ -0,0 +1,21 @@ +namespace Hc900Crawler.Core.Application.Feedforward; + +/// +/// 측류 스트림별 "추종(ON/OFF)" 상태. ON이면 Supervisor가 매 주기 권장 WSP를 연속으로 씀. +/// +public sealed record FfTrackingState +{ + public int ColumnId { get; init; } + public string StreamKey { get; init; } = ""; + public bool Enabled { get; init; } + public DateTime? StartedAt { get; init; } + public string Operator { get; init; } = "manual"; +} + +public interface IFfTrackingStore +{ + void Set(FfTrackingState state); + bool IsEnabled(int columnId, string streamKey); + FfTrackingState? Get(int columnId, string streamKey); + IReadOnlyCollection GetAll(); +} diff --git a/src/Core/Application/Interfaces/IExperionServices.cs b/src/Core/Application/Interfaces/IExperionServices.cs index f440436..26977f9 100644 --- a/src/Core/Application/Interfaces/IExperionServices.cs +++ b/src/Core/Application/Interfaces/IExperionServices.cs @@ -60,6 +60,9 @@ public interface IExperionDbService /// realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기 Task> GetRealtimeRecordsByTagNamesAsync(IEnumerable tagNames); + /// 태그(베이스 기준)가 어느 컨트롤러(C1~C4)에 속하는지 realtime_table에서 해석. 없으면 null. + Task GetControllerIdForTagAsync(string tagName); + // ── 공통 (이미 없는 경우만) ────────────────────────────────────────────────── Task GetNodeIdByTagNameAsync(string tagName); diff --git a/src/Hc900Crawler/Controllers/FastController.cs b/src/Hc900Crawler/Controllers/FastController.cs index 7d752b1..4cf6bdc 100644 --- a/src/Hc900Crawler/Controllers/FastController.cs +++ b/src/Hc900Crawler/Controllers/FastController.cs @@ -1,5 +1,7 @@ using Hc900Crawler.Core.Application.Interfaces; +using Hc900Crawler.Core.Domain.Entities; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; namespace Hc900Crawler.Web.Controllers; @@ -8,9 +10,11 @@ namespace Hc900Crawler.Web.Controllers; public class FastController : ControllerBase { private readonly IExperionDbService _db; + private readonly IServiceScopeFactory _scopeFactory; private static readonly Dictionary _sessions = new(); - public FastController(IExperionDbService db) => _db = db; + public FastController(IExperionDbService db, IServiceScopeFactory scopeFactory) + { _db = db; _scopeFactory = scopeFactory; } [HttpGet("sessions")] public async Task GetSessions() @@ -57,37 +61,52 @@ public class FastController : ControllerBase return Ok(new { session.Id, status = "Running" }); } + // 백그라운드 수집 루프. 요청 스코프는 응답 후 dispose되므로 매 반복 자체 스코프에서 DbContext를 새로 받는다. private async Task RunSessionAsync(int sessionId, FastSessionCreateRequest req, CancellationToken ct) { var endAt = DateTime.UtcNow.AddSeconds(req.DurationSec); int rowCount = 0; + string finalStatus = "Completed"; try { while (!ct.IsCancellationRequested && DateTime.UtcNow < endAt) { var now = DateTime.UtcNow; - var live = (await _db.GetRealtimeRecordsByTagNamesAsync(req.TagList)) - .ToDictionary(p => p.TagName, p => p.LiveValue); - var records = req.TagList.Select(t => new Hc900Crawler.Core.Domain.Entities.FastRecord + using (var scope = _scopeFactory.CreateScope()) { - SessionId = sessionId, - RecordedAt = now, - TagName = t, - Value = live.GetValueOrDefault(t) - }).ToList(); + var db = scope.ServiceProvider.GetRequiredService(); + // realtime_table에 같은 태그가 대/소문자 중복 저장될 수 있어 대소문자 무관 매칭 + var live = (await db.GetRealtimeRecordsByTagNamesAsync(req.TagList)) + .GroupBy(p => p.TagName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().LiveValue, StringComparer.OrdinalIgnoreCase); + var records = req.TagList.Select(t => new FastRecord + { + SessionId = sessionId, + RecordedAt = now, + TagName = t, + Value = live.GetValueOrDefault(t) + }).ToList(); - await _db.BatchInsertFastRecordsAsync(records); - rowCount += records.Count; - await _db.UpdateFastSessionRowCountAsync(sessionId, rowCount); + await db.BatchInsertFastRecordsAsync(records); + rowCount += records.Count; + await db.UpdateFastSessionRowCountAsync(sessionId, rowCount); + } await Task.Delay(req.SamplingMs, ct); } - await _db.UpdateFastSessionStatusAsync(sessionId, "Completed"); } - catch (OperationCanceledException) + catch (OperationCanceledException) { finalStatus = "Stopped"; } + catch (Exception) { finalStatus = "Failed"; } // 이전엔 조용히 죽어 'Running'으로 남았음 + finally { - await _db.UpdateFastSessionStatusAsync(sessionId, "Stopped"); + lock (_sessions) { _sessions.Remove(sessionId); } + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionStatusAsync(sessionId, finalStatus); + } + catch { /* best-effort */ } } - finally { lock (_sessions) { _sessions.Remove(sessionId); } } } [HttpPost("{id}/stop")] diff --git a/src/Hc900Crawler/Controllers/FeedforwardController.cs b/src/Hc900Crawler/Controllers/FeedforwardController.cs index 7ad84b1..3635ef2 100644 --- a/src/Hc900Crawler/Controllers/FeedforwardController.cs +++ b/src/Hc900Crawler/Controllers/FeedforwardController.cs @@ -31,14 +31,37 @@ public sealed class FeedforwardController : ControllerBase Hc900Crawler.Infrastructure.Control.FeedforwardSupervisor supervisor, ISimOverrideStore sim, Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService ramp, - ICompositionStore composition) + ICompositionStore composition, + IFeedRampJobStore rampJobs, + IExperionDbService db, + IFfTrackingStore tracking) { _store = store; _config = config; _audit = audit; _writeGuard = writeGuard; _writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor; - _sim = sim; _ramp = ramp; _composition = composition; } + _sim = sim; _ramp = ramp; _composition = composition; _rampJobs = rampJobs; _db = db; _tracking = tracking; } + + private readonly IFfTrackingStore _tracking; private readonly ISimOverrideStore _sim; private readonly Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService _ramp; private readonly ICompositionStore _composition; + private readonly IFeedRampJobStore _rampJobs; + private readonly IExperionDbService _db; + + // realtime_table에서 현재값(예: WSP) 1개 best-effort 조회 — 되돌리기/이전값 표시용 + private async Task TryReadCurrentAsync(string tag, CancellationToken ct) + { + try + { + var rows = await _db.GetRealtimeRecordsByTagNamesAsync(new[] { tag }); + var r = rows.FirstOrDefault(); + if (r?.LiveValue is not null && + double.TryParse(r.LiveValue, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return v; + } + catch { /* best-effort */ } + return null; + } private async Task AuthAsync(CancellationToken ct) => await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct); @@ -85,32 +108,83 @@ public sealed class FeedforwardController : ControllerBase if (cfg is null) return NotFound(new { error = "config 없음" }); var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey); if (sc is null) return NotFound(new { error = "stream 없음" }); - if (string.IsNullOrWhiteSpace(sc.SpNodeId)) - return BadRequest(new { error = "SP NodeId 미지정 — 설정에서 입력하세요" }); + // SP 쓰기 대상 = flow 태그에서 WSP(.SP, offset 0x04 RW) 자동 파생. SpNodeId는 선택적 override. + var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId); + if (string.IsNullOrWhiteSpace(spTag)) + return BadRequest(new { error = "flow 태그가 없어 SP 대상 산출 불가" }); var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey); if (adv is null) return NotFound(new { error = "stream advisory 없음" }); double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN); if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" }); - // WriteGuard 검증 - var check = _writeGuard.Check(cfg, adv, sc, advisory); + // 범위 클램프(§3.3) 후 WriteGuard 검증 — manualOverride=true(AdvisoryOnly만 우회, 나머지 가드 유지) + spVal = Math.Clamp(spVal, sc.SpMin, sc.SpMax); + var check = _writeGuard.Check(cfg, adv, sc, advisory, manualOverride: true); if (!check.Allowed) return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" }); - // HC900 gRPC 쓰기 (SpNodeId를 HC900 태그명으로 사용) - var (success, error) = await _writeClient.WriteTagAsync("HC1", sc.SpNodeId, spVal); + // 되돌리기용: 쓰기 전 현재 WSP 값을 캡처(realtime_table) + double? prevSp = await TryReadCurrentAsync(spTag, ct); - // 감사 로그 + // 컨트롤러는 태그→controller_id 매핑에서 해석(DB). 컬럼 ControllerId에 의존하지 않음. + var ctrlId = await _db.GetControllerIdForTagAsync(spTag); + if (string.IsNullOrWhiteSpace(ctrlId)) + return BadRequest(new { error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" }); + + // HC900 gRPC 쓰기 (WSP 태그, 해석된 컨트롤러로 라우팅) + var (success, error) = await _writeClient.WriteTagAsync(ctrlId, spTag, spVal); + + // 감사 로그(이전값 포함 — 되돌리기 근거) await _audit.LogAsync(new FfActionLogEntry(columnId, "sp_write", - StreamKey: streamKey, SpValue: spVal, NodeId: sc.SpNodeId, - Result: success ? "success" : $"error: {error}", + StreamKey: streamKey, SpValue: spVal, NodeId: spTag, + Result: success ? $"success (prev={prevSp?.ToString("F2") ?? "n/a"})" : $"error: {error}", OperatorName: "manual"), ct); if (!success) return StatusCode(502, new { error = $"HC900 쓰기 실패: {error}" }); - return Ok(new { success = true, streamKey, nodeId = sc.SpNodeId, value = spVal }); + return Ok(new { success = true, streamKey, nodeId = spTag, value = spVal, previousSp = prevSp }); + } + + // ── 측류 추종 ON/OFF/원복 ─────────────────────────────────────── + // ON: baseline(현재 WSP) 캡처 후 추종 시작 → Supervisor가 매 주기 권장 WSP를 연속 씀 + [HttpPost("track/{columnId:int}/{streamKey}/on")] + public async Task TrackOn(int columnId, string streamKey, CancellationToken ct) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId); + if (cfg is null) return NotFound(new { error = "config 없음" }); + var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey); + if (sc is null) return NotFound(new { error = "stream 없음" }); + if (sc.Role == StreamRole.Monitor) return BadRequest(new { error = "Monitor 스트림은 추종 대상 아님" }); + var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId); + if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { error = "flow 태그 없음 — SP 대상 산출 불가" }); + + _tracking.Set(new FfTrackingState + { + ColumnId = columnId, StreamKey = streamKey, Enabled = true, + StartedAt = DateTime.UtcNow, Operator = "manual" + }); + await _audit.LogAsync(new FfActionLogEntry(columnId, "track_on", + StreamKey: streamKey, NodeId: spTag, Result: "started", OperatorName: "manual"), ct); + return Ok(new { success = true, streamKey }); + } + + // OFF(=취소): 추종 중지. 컨트롤러는 마지막 값 유지. + [HttpPost("track/{columnId:int}/{streamKey}/off")] + public async Task TrackOff(int columnId, string streamKey, CancellationToken ct) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + var cur = _tracking.Get(columnId, streamKey); + _tracking.Set(new FfTrackingState + { + ColumnId = columnId, StreamKey = streamKey, Enabled = false, + StartedAt = cur?.StartedAt, Operator = "manual" + }); + await _audit.LogAsync(new FfActionLogEntry(columnId, "track_off", + StreamKey: streamKey, Result: "stopped", OperatorName: "manual"), ct); + return Ok(new { success = true }); } // ── Phase II: 감사 로그 조회 ── @@ -125,6 +199,9 @@ public sealed class FeedforwardController : ControllerBase private object MapConfig(ColumnConfig c) => new { id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly, + controllerId = c.ControllerId, + feedSpNodeId = c.FeedSpNodeId, feedSpMin = c.FeedSpMin, + feedSpMax = double.IsInfinity(c.FeedSpMax) || c.FeedSpMax >= double.MaxValue ? 1e9 : c.FeedSpMax, feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags, scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec, feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec, @@ -139,6 +216,11 @@ public sealed class FeedforwardController : ControllerBase feedRecoverySp = c.FeedRecoverySp, deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit, tempHighLimit = c.TempHighLimit, + tempLowLimit = c.TempLowLimit, + tcReturnRebTarget = double.IsNaN(c.TcReturnRebTarget) ? (double?)null : c.TcReturnRebTarget, + tcReturnRebBand = c.TcReturnRebBand, + tcReturnDeltaAdRef = double.IsNaN(c.TcReturnDeltaAdRef) ? (double?)null : c.TcReturnDeltaAdRef, + tcReturnDeltaAdBand = c.TcReturnDeltaAdBand, streams = c.Streams.Select(s => new { key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, @@ -163,6 +245,76 @@ public sealed class FeedforwardController : ControllerBase return Ok(MapRamp(a)); } + // ── 작업 B: FEED 램프 실행 (시작/취소/상태) ─────────────────────── + private bool RampDryRun() => _appConfig.GetValue("Feedforward:FeedRampDryRun") ?? true; + + [HttpPost("feed-ramp/{columnId:int}/start")] + public async Task StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed)) + return BadRequest(new { error = "targetFeed 값 필요" }); + + var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId); + if (cfg is null) return NotFound(new { error = "config 없음" }); + if (string.IsNullOrWhiteSpace(FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId))) + return BadRequest(new { error = "Feed 태그 없음 — FEED SP 대상 산출 불가" }); + + // 현재 피드 확인 + 업램프만 허용 + var adv = await _ramp.ComputeAsync(columnId, body.targetFeed, 50, double.NaN, double.NaN, double.NaN, 1.8, ct); + if (adv is null) return NotFound(new { error = "config 없음" }); + if (adv.Hold) return BadRequest(new { error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" }); + if (body.targetFeed <= adv.CurrentFeed) + return BadRequest(new { error = $"업램프만 지원 — 목표({body.targetFeed:F1})가 현재 피드({adv.CurrentFeed:F1}) 이하" }); + + bool dryRun = RampDryRun() || _sim.Enabled; + var job = _rampJobs.Start(columnId, body.targetFeed, "manual", dryRun); + + await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_start", + SpValue: body.targetFeed, NodeId: cfg.FeedSpNodeId, + Result: dryRun ? "started(dry-run)" : "started", OperatorName: "manual"), ct); + + return Ok(new { success = true, dryRun, job = MapRampJob(job) }); + } + + [HttpPost("feed-ramp/{columnId:int}/cancel")] + public async Task CancelFeedRamp(int columnId, CancellationToken ct) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + bool ok = _rampJobs.Cancel(columnId); + if (ok) + await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel", + Result: "canceled", OperatorName: "manual"), ct); + return Ok(new { success = ok }); + } + + [HttpGet("feed-ramp/{columnId:int}")] + public IActionResult GetFeedRamp(int columnId) + { + var job = _rampJobs.Get(columnId); + return Ok(new { dryRun = RampDryRun() || _sim.Enabled, job = job is null ? null : MapRampJob(job) }); + } + + [HttpGet("feed-ramp")] + public IActionResult GetAllFeedRamp() + => Ok(new { dryRun = RampDryRun() || _sim.Enabled, jobs = _rampJobs.GetAll().Select(MapRampJob) }); + + private static object MapRampJob(FeedRampJob j) => new + { + columnId = j.ColumnId, + targetFeed = Fin(j.TargetFeed), + lastWrittenSp = double.IsNaN(j.LastWrittenSp) ? (double?)null : j.LastWrittenSp, + state = j.State.ToString(), + hold = j.Hold, + currentFeed = j.CurrentFeed, + ceiling = j.Ceiling, + rampRate = j.RampRate, + startedAt = j.StartedAt, + lastStepAt = j.LastStepAt, + dryRun = j.DryRun, + warnings = j.Warnings + }; + // ── WP0: Sim Override (DEMO 게이트, 입력 치환 — 제어 쓰기 아님) ── private bool SimEnabled() => _appConfig.GetValue("Feedforward:SimOverrideEnabled"); @@ -219,10 +371,7 @@ public sealed class FeedforwardController : ControllerBase // ── Advisory (공개 읽기) ─────────────────────────────────────── [HttpGet("advisory")] - public IActionResult GetAll() => Ok(new - { - columns = _store.GetAll().Select(r => MapColumn(r)) - }); + public IActionResult GetAll() => Ok(new { columns = _store.GetAll().Select(MapColumn) }); [HttpGet("advisory/{columnId:int}")] public IActionResult Get(int columnId) @@ -236,14 +385,19 @@ public sealed class FeedforwardController : ControllerBase var streams = r.Streams.Select(s => { var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key); + // SP는 flow 태그에서 .SP(WSP) 자동 파생 → Commanded/LevelDriven(피드결정형 D·B)·flow태그 있으면 추종 가능 + bool writable = s.Role != StreamRole.Monitor && !string.IsNullOrWhiteSpace(s.FlowTag); + var trk = _tracking.Get(r.ColumnId, s.Key); return new { + tracking = trk?.Enabled ?? false, key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv, recommendedSp = s.RecommendedSp, + writable = writable, // SP 태그 설정됨 → 측류 SP 쓰기 버튼 노출 가능 gap = s.Gap, trend = s.Trend, valid = s.Valid, @@ -309,6 +463,8 @@ public sealed class FeedforwardController : ControllerBase public sealed record WriteSpBody { public double? value { get; init; } } +public sealed record FeedRampStartBody { public double targetFeed { get; init; } = double.NaN; } + public sealed record SimOverrideBody { public bool enabled { get; init; } diff --git a/src/Hc900Crawler/Controllers/Hc900Controllers.cs b/src/Hc900Crawler/Controllers/Hc900Controllers.cs index b402b00..42f32f3 100644 --- a/src/Hc900Crawler/Controllers/Hc900Controllers.cs +++ b/src/Hc900Crawler/Controllers/Hc900Controllers.cs @@ -201,7 +201,7 @@ public class EventHistoryController : ControllerBase { var result = await _db.QueryEventHistoryAsync( dto.TagName, dto.Area, dto.SubArea, dto.EventType, - dto.From ?? DateTime.UtcNow.AddDays(-1), + dto.From ?? DateTime.UtcNow.AddDays(-7), dto.To ?? DateTime.UtcNow, dto.Limit); return Ok(result); @@ -236,12 +236,21 @@ public class Hc900TagManagerController : ControllerBase [FromQuery] string? paramType = null, [FromQuery] int? loopNo = null, [FromQuery] bool? active = null, + [FromQuery] bool? hasLive = null, [FromQuery] string? search = null) { var q = _ctx.Hc900MapEntries.AsQueryable(); if (!string.IsNullOrEmpty(controllerId)) q = q.Where(x => x.ControllerId == controllerId); if (active.HasValue) q = q.Where(x => x.IsActive == active.Value); + if (hasLive == true) + { + var liveTagNames = await _ctx.RealtimePoints + .Select(r => r.TagName) + .Distinct() + .ToListAsync(); + q = q.Where(x => liveTagNames.Contains(x.TagName)); + } if (!string.IsNullOrEmpty(paramType)) q = q.Where(x => x.ParamType == paramType); if (loopNo.HasValue) q = q.Where(x => x.LoopNo == loopNo.Value); if (!string.IsNullOrEmpty(search)) diff --git a/src/Hc900Crawler/Controllers/OllamaController.cs b/src/Hc900Crawler/Controllers/OllamaController.cs index 4b455a7..0651aef 100644 --- a/src/Hc900Crawler/Controllers/OllamaController.cs +++ b/src/Hc900Crawler/Controllers/OllamaController.cs @@ -489,7 +489,7 @@ public class OllamaController : ControllerBase public IActionResult GetConfig() { var cfg = LoadConfig(); - return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl }); + return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl, vllmModel = LoadVllmModel() }); } [HttpPost("config")] diff --git a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs index 2b53353..aa0972f 100644 --- a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs +++ b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs @@ -1,5 +1,9 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Hc900Crawler.Infrastructure.Control; +using Hc900Crawler.Infrastructure.Database; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace Hc900Crawler.Web.Controllers; @@ -7,13 +11,42 @@ namespace Hc900Crawler.Web.Controllers; [Route("api/steam")] public sealed class SteamAdvisorController : ControllerBase { - private readonly SteamAdvisor _advisor; + private static readonly Dictionary _tagDescs = new(StringComparer.OrdinalIgnoreCase); - public SteamAdvisorController(SteamAdvisor advisor) + private readonly SteamAdvisor _advisor; + private readonly Hc900DbContext _ctx; + private readonly IConfiguration _config; + + public SteamAdvisorController(SteamAdvisor advisor, Hc900DbContext ctx, IConfiguration config) { _advisor = advisor; + _ctx = ctx; + _config = config; + EnsureTagDescs(); } + private static void EnsureTagDescs() + { + if (_tagDescs.Count > 0) return; + try + { + var path = "/home/windpacer/projects/hc900_ax/docs/register-map.json"; + if (!System.IO.File.Exists(path)) return; + var json = System.IO.File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + foreach (var reg in doc.RootElement.GetProperty("registers").EnumerateArray()) + { + var tag = reg.GetProperty("tag").GetString(); + var desc = reg.GetProperty("description").GetString(); + if (tag != null && desc != null) + _tagDescs[tag] = desc; + } + } + catch { /* non-fatal */ } + } + + private string? GetDesc(string? tag) => tag != null && _tagDescs.TryGetValue(tag, out var d) ? d : null; + [HttpGet("health")] public IActionResult Health() { @@ -36,6 +69,227 @@ public sealed class SteamAdvisorController : ControllerBase var result = _advisor.Predict(body.Feed, body.Product, body.TC); return Ok(result); } + + [HttpGet("models")] + public IActionResult ListModels() + { + var modelDir = _config.GetValue("SteamAdvisor:ModelDir") + ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; + if (!Directory.Exists(modelDir)) + return Ok(new { columns = Array.Empty() }); + + var columns = Directory.GetFiles(modelDir, "*_model.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(n => n != null) + .Select(n => n!.Replace("_model", "")) + .OrderBy(x => x) + .ToList(); + + var configured = _config.GetSection("SteamAdvisor:Columns").GetChildren() + .Select(c => c.Key) + .ToHashSet(); + + return Ok(new { columns, configured = configured.OrderBy(x => x).ToList() }); + } + + [HttpGet("backtest/{col}")] + public async Task Backtest(string col) + { + var plotDir = _config.GetValue("SteamAdvisor:PlotDataDir") + ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; + var path = Path.Combine(plotDir, $"{col}_plotdata.json"); + if (!System.IO.File.Exists(path)) + return NotFound(new { error = $"플롯데이터 없음: {col}" }); + + try + { + var json = await System.IO.File.ReadAllTextAsync(path); + return Content(json, "application/json", System.Text.Encoding.UTF8); + } + catch (Exception ex) + { + return StatusCode(500, new { error = $"플롯데이터 읽기 실패: {ex.Message}" }); + } + } + + [HttpGet("live")] + public async Task Live([FromQuery] string? col = null) + { + col ??= _config.GetValue("SteamAdvisor:DefaultColumn") ?? "c6111"; + var tags = _config.GetSection($"SteamAdvisor:Columns:{col}"); + var feedTag = tags["Feed"]; + var productTag = tags["Product"]; + var tcTag = tags["TC"]; + var steamOpTag = tags["SteamOp"]; + var steamFlowTag = tags["SteamFlow"]; + + if (string.IsNullOrEmpty(feedTag) || string.IsNullOrEmpty(productTag) || string.IsNullOrEmpty(tcTag)) + return BadRequest(new { error = $"컬럼 {col} 태그 매핑 불완전" }); + + var tagNames = new[] { feedTag, productTag, tcTag }; + if (!string.IsNullOrEmpty(steamOpTag)) tagNames = tagNames.Append(steamOpTag).ToArray(); + if (!string.IsNullOrEmpty(steamFlowTag)) tagNames = tagNames.Append(steamFlowTag).ToArray(); + + var live = await _ctx.RealtimePoints + .Where(r => tagNames.Contains(r.TagName)) + .ToDictionaryAsync(r => r.TagName, r => r.LiveValue); + + if (!live.ContainsKey(feedTag) || !live.ContainsKey(productTag) || !live.ContainsKey(tcTag)) + { + var missing = new[] { feedTag, productTag, tcTag } + .Where(t => !live.ContainsKey(t)) + .ToList(); + return Ok(new { col, status = "missing_tags", missing, + message = "일부 태그 실시간값 없음 — 게이트웨이 폴링 확인" }); + } + + var feed = double.TryParse(live[feedTag], out var f) ? f : double.NaN; + var product = double.TryParse(live[productTag], out var p) ? p : double.NaN; + var tC = double.TryParse(live[tcTag], out var tc) ? tc : double.NaN; + + var result = _advisor.Predict(feed, product, tC); + + double? steamOp = null; + if (!string.IsNullOrEmpty(steamOpTag) && live.TryGetValue(steamOpTag, out var sopStr) && double.TryParse(sopStr, out var sop)) + steamOp = sop; + double? steamFlow = null; + if (!string.IsNullOrEmpty(steamFlowTag) && live.TryGetValue(steamFlowTag, out var sfStr) && double.TryParse(sfStr, out var sf)) + steamFlow = sf; + + return Ok(new + { + col, + result.RecOp, result.RecSteam, result.Confidence, + result.Mode, result.Ood, result.InEnv, result.Message, + Feed = result.Feed, + Product = result.Product, + TC = result.TC, + ActualOp = steamOp, + ActualSteamFlow = steamFlow, + Tags = new { feed = feedTag, product = productTag, tC = tcTag, steamOp = steamOpTag, steamFlow = steamFlowTag }, + Descs = new { feed = GetDesc(feedTag), product = GetDesc(productTag), tC = GetDesc(tcTag), steamOp = GetDesc(steamOpTag), steamFlow = GetDesc(steamFlowTag) }, + Timestamp = DateTime.UtcNow, + }); + } + + // ── 레이어②: 컬럼 온도 프로파일 이격 모니터 ──────────────────────── + // realtime 단별 온도/진공 vs 기준밴드(c{col}_tempref.json) → 제품매칭 + z-score 이격. + // col 규약 = 분석 prefix("61","62","81","91","92","101","102"). + [HttpGet("tempprofile/{col}")] + public async Task TempProfile(string col) + { + var dir = _config.GetValue("SteamAdvisor:ModelDir") + ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; + var path = Path.Combine(dir, $"c{col}_tempref.json"); + if (!System.IO.File.Exists(path)) + return NotFound(new { error = $"기준 프로파일 없음: c{col}_tempref.json" }); + + var tref = JsonSerializer.Deserialize( + await System.IO.File.ReadAllTextAsync(path), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (tref is null) return StatusCode(500, new { error = "tempref 파싱 실패" }); + + var tagMap = TagsFor(col); + var tagNames = tagMap.Values.ToArray(); + var live = await _ctx.RealtimePoints + .Where(r => tagNames.Contains(r.TagName)) + .ToDictionaryAsync(r => r.TagName, r => r.LiveValue); + double? Val(string tag) + => live.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? v : null; + var cur = tagMap.ToDictionary(kv => kv.Key, kv => Val(kv.Value)); + + // 제품 매칭: 현재 reb_temp에 가장 가까운 기준 제품(온도프로파일=제품 식별자) + TempProduct? prod = null; + if (cur["reb_temp"] is double reb && tref.Products.Count > 0) + prod = tref.Products + .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb)) + .First(); + + static double? Z(double? c, TempStat? r) + => (c is double cv && r is not null && r.Std > 1e-6) ? (cv - r.Median) / r.Std : null; + + var stages = tref.StagesOrder.Select(s => + { + var c = cur.GetValueOrDefault(s); + var rs = prod?.Stages.GetValueOrDefault(s); + var z = Z(c, rs); + return (object)new + { + stage = s, current = c, refMedian = rs?.Median, refStd = rs?.Std, + z, deviated = z is double zz && Math.Abs(zz) > 2 + }; + }).ToList(); + + var vac = cur.GetValueOrDefault("vacuum"); + var vz = Z(vac, prod?.Vacuum); + double? spanAD = (cur["reb_temp"] is double r2 && cur["T_D"] is double td) ? r2 - td : null; + + return Ok(new + { + column = tref.Column, + period = tref.Period, + matchedProduct = prod?.Label, + nProducts = tref.NProducts, + stages, + vacuum = new + { + current = vac, refMedian = prod?.Vacuum.Median, refStd = prod?.Vacuum.Std, + z = vz, deviated = vz is double v && Math.Abs(v) > 2 + }, + spanAD, + spanRef = prod?.SpanAD, + products = tref.Products, + timestamp = DateTime.UtcNow, + }); + } + + // roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응. + private static Dictionary TagsFor(string p) + { + var m = new Dictionary + { + ["reb_temp"] = $"TICA-{p}11A.PV", + ["T_B"] = $"TI-{p}11B.PV", + ["T_C"] = $"TI-{p}11C.PV", + ["T_D"] = $"TI-{p}11D.PV", + ["vacuum"] = $"PICA-{p}11.PV", + }; + switch (p) + { + case "51": m["T_C"] = "TI-5111B.PV"; break; + case "81": m["reb_temp"] = "TICA-8111.PV"; m["vacuum"] = "PICA-8111A.PV"; break; + case "91": m["vacuum"] = "PICA-9111A.PV"; break; + case "92": m["vacuum"] = "PICA-9211A.PV"; break; + case "101": m["vacuum"] = "PICA-10111A.PV"; break; + case "102": m["vacuum"] = "PICA-10211A.PV"; break; + } + return m; + } +} + +// ── tempref.json 역직렬화 (gen_temp_profiles.py 산출 구조) ── +public sealed record TempRef +{ + public string Column { get; init; } = ""; + [JsonPropertyName("stages_order")] public List StagesOrder { get; init; } = []; + [JsonPropertyName("n_products")] public int NProducts { get; init; } + public string Period { get; init; } = ""; + public List Products { get; init; } = []; +} + +public sealed record TempProduct +{ + public string Label { get; init; } = ""; + [JsonPropertyName("n_rows")] public int NRows { get; init; } + [JsonPropertyName("span_AD")] public double SpanAD { get; init; } + public TempStat Vacuum { get; init; } = new(); + public Dictionary Stages { get; init; } = []; +} + +public sealed record TempStat +{ + public double Median { get; init; } + public double Std { get; init; } } public sealed record SteamPredictBody diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index 82610e0..83b754d 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -65,7 +65,20 @@ } }, "SteamAdvisor": { - "ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json" + "ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json", + "PlotDataDir": "/home/windpacer/projects/hc900_ax/scripts/analysis", + "ModelDir": "/home/windpacer/projects/hc900_ax/scripts/analysis", + "DefaultColumn": "c6111", + "Columns": { + "c6111": { "Feed": "FICQ-6101.PV", "Product": "FICQ-6118.PV", "TC": "TI-6111C", "SteamOp": "TICA-6111A.OP", "SteamFlow": "FIQ-6115" }, + "c61": { "Feed": "FICQ-6101.PV", "Product": "FICQ-6118.PV", "TC": "TI-6111C", "SteamOp": "TICA-6111A.OP", "SteamFlow": "FIQ-6115" }, + "c62": { "Feed": "FICQ-6201.PV", "Product": "FICQ-6218.PV", "TC": "TI-6211C", "SteamOp": "TICA-6211A.OP", "SteamFlow": "FIQ-6215" }, + "c81": { "Feed": "FIQ-8111.PV", "Product": "FICQ-8118.PV", "TC": "TI-8111C", "SteamOp": "TICA-8111A.OP", "SteamFlow": "FIQ-8115" }, + "c91": { "Feed": "FIQ-9111.PV", "Product": "FICQ-9118.PV", "TC": "TI-9111C", "SteamOp": "TICA-9111A.OP", "SteamFlow": "FIQ-9115" }, + "c92": { "Feed": "FIQ-9211.PV", "Product": "FICQ-9218.PV", "TC": "TI-9211C", "SteamOp": "TICA-9211A.OP", "SteamFlow": "FIQ-9215" }, + "c101": { "Feed": "FIQ-10111.PV", "Product": "FICQ-10118.PV", "TC": "TI-10111C", "SteamOp": "TICA-10111A.OP", "SteamFlow": "FIQ-10115" }, + "c102": { "Feed": "FIQ-10211.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" } + } }, "Kestrel": { "Endpoints": { diff --git a/src/Hc900Crawler/wwwroot/css/ff.css b/src/Hc900Crawler/wwwroot/css/ff.css index ebc9ea7..0644cef 100644 --- a/src/Hc900Crawler/wwwroot/css/ff.css +++ b/src/Hc900Crawler/wwwroot/css/ff.css @@ -75,6 +75,15 @@ .ff-trig{border-color:#ff8a80 !important} /* Phase II: auto-write */ .ff-write-badge{font-size:10px;background:#003a4d;color:#7fd1ff;padding:2px 8px;border-radius:8px;margin-left:6px} +.ff-track-on{font-size:10px;background:#0c4a1e;color:#79e08a;padding:2px 7px;border-radius:8px;font-weight:600} +.ff-track-btn{background:#0c4a1e !important;color:#bdf0c6 !important;border-color:#1c7a3a !important} +/* 추종 컨트롤: 추종● 위 / OFF·원복 아래 한 줄 — 가로폭 안 늘어나게(값 카드밖 밀림 방지) */ +.ff-track-ctl{display:flex;flex-direction:column;align-items:flex-start;gap:2px;margin-top:3px} +.ff-track-row{display:flex;gap:3px} +.ff-track-ctl .btn{padding:1px 6px;font-size:11px;line-height:1.4} +.ff-feedramp{display:flex;align-items:center;gap:6px;margin:4px 0;font-size:12px;white-space:nowrap} +.ff-feedramp .ff-rt{width:90px} +.ff-rt-mode{font-size:11px;opacity:.7} .ff-write{font-size:10px;color:#7fd1ff;opacity:.8} .ff-write-err{color:#ff8a80} .ff-wg-blocked{font-size:12px;color:#ff8a80;background:#3a0000;padding:4px 8px;border-radius:4px;margin:4px 0} diff --git a/src/Hc900Crawler/wwwroot/index.html b/src/Hc900Crawler/wwwroot/index.html index 7bb212c..d575eb2 100644 --- a/src/Hc900Crawler/wwwroot/index.html +++ b/src/Hc900Crawler/wwwroot/index.html @@ -93,6 +93,10 @@ 12 유량 권장(FF) +
@@ -103,19 +107,32 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
@@ -197,22 +214,23 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/Hc900Crawler/wwwroot/js/fast.js b/src/Hc900Crawler/wwwroot/js/fast.js index 090ac21..1571969 100644 --- a/src/Hc900Crawler/wwwroot/js/fast.js +++ b/src/Hc900Crawler/wwwroot/js/fast.js @@ -81,12 +81,12 @@ async function fastSessionsLoad() { }; data.items.forEach(s => { - const isActive = s.id === fastCurrentSessionId; - const dot = statusColor[s.status] ?? '#aaa'; - const label = statusLabel[s.status] ?? s.status; + const isActive = s.Id === fastCurrentSessionId; + const dot = statusColor[s.Status] ?? '#aaa'; + const label = statusLabel[s.Status] ?? s.Status; const chip = document.createElement('div'); - chip.dataset.id = s.id; + chip.dataset.id = s.Id; chip.style.cssText = [ 'display:flex;flex-direction:column;gap:3px', 'padding:7px 11px;border-radius:var(--r)', @@ -99,18 +99,18 @@ async function fastSessionsLoad() { chip.innerHTML = `
- ${esc(s.name)} - ${s.pinned ? '📌' : ''} - + ${esc(s.Name)} + ${s.Pinned ? '📌' : ''} +
-
${label} · ${s.tagCount}태그 · ${s.samplingMs}ms
-
${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}
+
${label} · ${s.TagCount}태그 · ${s.SamplingMs}ms
+
${fastFormatDuration(s.DurationSec)} · ${fastFormatDateTime(s.StartedAt).slice(0,10)}
`; chip.querySelector('[data-del]').addEventListener('click', e => { e.stopPropagation(); - fastDelete(s.id); + fastDelete(s.Id); }); - chip.onclick = () => fastSelect(s.id); + chip.onclick = () => fastSelect(s.Id); list.appendChild(chip); }); } @@ -144,7 +144,7 @@ async function fastStart() { const data = await res.json(); fastModalClose(); await fastSessionsLoad(); - fastSelect(data.id); + fastSelect(data.Id); } async function fastStop(id) { @@ -196,9 +196,9 @@ async function fastSelect(id) { if (!res.ok) { alert('세션 조회 실패'); return; } const session = await res.json(); - document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`; + document.getElementById('fast-session-title').textContent = `${session.Name} (${session.Status})`; - const isRunning = session.status === 'Running'; + const isRunning = session.Status === 'Running'; const isFinished = !isRunning; document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none'; @@ -206,7 +206,7 @@ async function fastSelect(id) { document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none'; document.getElementById('btn-fast-delete').style.display = 'inline-block'; document.getElementById('btn-fast-pin').style.display = 'inline-block'; - document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정'; + document.getElementById('btn-fast-pin').textContent = session.Pinned ? '고정 해제' : '고정'; await fastRenderChart(); await fastUpdateProgress(session); @@ -236,8 +236,8 @@ async function fastRenderChart() { // Long 포맷 → PIVOT (recorded_at 기준 그룹화) const grouped = {}; for (const r of data.items) { - if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {}; - grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null; + if (!grouped[r.RecordedAt]) grouped[r.RecordedAt] = {}; + grouped[r.RecordedAt][r.TagName] = parseFloat(r.Value) || null; } const times = Object.keys(grouped).sort(); @@ -277,12 +277,36 @@ async function fastRenderChart() { stroke: fastTagColor(tag), width: 2 })) - ] + ], + plugins: [ fastWheelZoomX() ] }; fastChart = new uPlot(opts, uData, container); } +// 휠로 X축(수평) 줌 — 커서 위치 중심. 드래그 선택 줌(기본)·더블클릭 리셋(기본)은 그대로. +function fastWheelZoomX() { + return { + hooks: { + ready: u => { + u.over.addEventListener('wheel', e => { + e.preventDefault(); + const rect = u.over.getBoundingClientRect(); + const leftPx = e.clientX - rect.left; + const xVal = u.posToVal(leftPx, 'x'); + const xMin = u.scales.x.min, xMax = u.scales.x.max; + const range = xMax - xMin; + if (!isFinite(range) || range <= 0) return; + const factor = e.deltaY < 0 ? 0.8 : 1.25; // 위로=확대, 아래로=축소 + const leftPct = (xVal - xMin) / range; + const nRange = range * factor; + u.setScale('x', { min: xVal - leftPct * nRange, max: xVal + (1 - leftPct) * nRange }); + }, { passive: false }); + } + } + }; +} + function fastClearChart() { fastChartTagNames = null; if (fastChart) { @@ -305,7 +329,7 @@ function fastLivePollStart() { const session = await res.json(); await fastUpdateProgress(session); await fastRenderChart(); - if (session.status !== 'Running') { + if (session.Status !== 'Running') { fastLivePollStop(); await fastSelect(fastCurrentSessionId); } @@ -320,16 +344,16 @@ function fastLivePollStop() { } async function fastUpdateProgress(session) { - const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000); - const progress = Math.min((elapsed / session.durationSec) * 100, 100); + const elapsed = Math.floor((Date.now() - new Date(session.StartedAt).getTime()) / 1000); + const progress = Math.min((elapsed / session.DurationSec) * 100, 100); document.getElementById('fast-progress-bar').style.width = `${progress}%`; - const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0; + const expectedRows = Math.floor(elapsed / (session.SamplingMs / 1000)) * session.TagList?.length ?? 0; document.getElementById('fast-progress-text').textContent = - `${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`; + `${session.RowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`; document.getElementById('fast-elapsed-time').textContent = - `경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`; + `경과: ${fastFormatDuration(Math.min(elapsed, session.DurationSec))} / ${fastFormatDuration(session.DurationSec)}`; } // ═══════════════════════════════════════════════════════════════ @@ -386,7 +410,7 @@ paneInit.fast = function() { document.getElementById('modal-fast-new').style.display = 'flex'; try { - const res = await fetch('/api/gateway/tags?limit=500'); + const res = await fetch('/api/gateway/tags?limit=0'); if (res.ok) { const data = await res.json(); select.innerHTML = ''; @@ -394,6 +418,12 @@ paneInit.fast = function() { const opt = document.createElement('option'); opt.value = p.tagName; opt.textContent = p.tagName; + // 멀티선택 UX: Ctrl 없이 클릭으로 토글(네이티브 단일선택 동작 방지) + opt.addEventListener('mousedown', e => { + e.preventDefault(); + opt.selected = !opt.selected; + select.focus(); + }); select.appendChild(opt); }); } else { @@ -429,8 +459,8 @@ paneInit.fast = function() { // Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달) const timeMap = {}; for (const r of data.items) { - if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {}; - timeMap[r.recordedAt][r.tagName] = r.value; + if (!timeMap[r.RecordedAt]) timeMap[r.RecordedAt] = {}; + timeMap[r.RecordedAt][r.TagName] = r.Value; } const rows = [['recorded_at', ...data.tagNames]]; diff --git a/src/Hc900Crawler/wwwroot/js/ff.js b/src/Hc900Crawler/wwwroot/js/ff.js index 43aff71..65762dd 100644 --- a/src/Hc900Crawler/wwwroot/js/ff.js +++ b/src/Hc900Crawler/wwwroot/js/ff.js @@ -3,6 +3,9 @@ paneInit.ff = ffInit; let ffTimer = null; +let ffRampJobs = {}; // columnId → FEED 램프 Job 상태 +let ffRampDryRun = true; +let ffRampTargets = {}; // columnId → 입력 중인 FEED Target SP(폴링 재렌더에도 보존) function ffToken() { return sessionStorage.getItem('kbToken') || ''; } @@ -30,6 +33,7 @@ async function ffInit() { r.style.display = r.style.display === 'none' ? 'block' : 'none'; }; document.getElementById('ff-ramp-go').onclick = ffRampCompute; + document.getElementById('ff-ramp-start').onclick = ffRampStart; document.getElementById('ff-new').onclick = () => ffEditColumn(null); ffLoadConfig().catch(()=>{}); @@ -75,6 +79,22 @@ async function ffRampCompute() { } } +// ── 작업 B: FEED 램프 시작 ──────────────────────────────────── +async function ffRampStart() { + const g = id => document.getElementById(id); + const colId = +g('ff-ramp-colId').value; + const target = +g('ff-ramp-targetFeed').value; + if (!Number.isFinite(colId) || !Number.isFinite(target)) { alert('columnId·targetFeed 확인'); return; } + const mode = ffRampDryRun ? '모의(DryRun — 실제 쓰기 없음)' : '실쓰기'; + if (!confirm(`컬럼 ${colId} FEED를 ${target}까지 단계적으로 올립니다 [${mode}]. 시작하시겠습니까?`)) return; + try { + const r = await ffApi('POST', `/api/ff/feed-ramp/${colId}/start`, { targetFeed: target }); + const modeEl = g('ff-ramp-mode'); + if (modeEl) modeEl.textContent = r.dryRun ? '모의(DryRun)' : '실쓰기 모드'; + ffLoadDash(); + } catch (e) { alert('램프 시작 실패: ' + e.message); } +} + // ── WP7: Sim Override ───────────────────────────────────────── async function ffSimLoad() { try { @@ -146,8 +166,18 @@ async function ffLoadDash() { let data; try { data = await api('GET', '/api/ff/advisory'); } catch (e) { return; } + try { + const ramp = await api('GET', '/api/ff/feed-ramp'); + ffRampJobs = {}; (ramp.jobs || []).forEach(j => ffRampJobs[j.columnId] = j); + ffRampDryRun = !!ramp.dryRun; + const modeEl = document.getElementById('ff-ramp-mode'); + if (modeEl) modeEl.textContent = ffRampDryRun ? '모의(DryRun)' : '실쓰기 모드'; + } catch (e) { /* 무시 */ } const host = document.getElementById('ff-dash'); if (!host) { clearInterval(ffTimer); ffTimer = null; return; } + // FEED Target SP 입력 중이면 재렌더 보류(타이핑/포커스 유지) + const ae = document.activeElement; + if (ae && ae.classList && ae.classList.contains('ff-rt')) return; const cols = data.columns || []; if (!cols.length) { host.innerHTML = '
활성 컬럼 없음
'; return; } host.innerHTML = cols.map(ffCard).join(''); @@ -162,6 +192,60 @@ function ffArm(id) { function ffCancelRecovery(id) { ffApi('POST', `/api/ff/recovery/${id}/cancel`).then(()=>ffLoadDash()).catch(()=>{}); } +// 측류 추종 컨트롤 — ON이면 권장 WSP 연속 추종. OFF=취소(현재값 유지), 원복=시작 전 WSP로 복귀. +// (Commanded·LevelDriven 모두 — D·B처럼 피드결정형이면 LevelDriven도 추종 가능) +function ffTrackCtl(c, s) { + if (!s.writable) return ''; + if (s.tracking) { + return `
추종 ●` + + `
`; + } + // OFF 상태 — 추종 시작 가능 조건(WriteGuard와 동일: 유효·과도아님·신뢰≠C) + const canOn = s.recommendedSp != null && s.valid && s.grade !== 'C' && !c.transient; + if (!canOn) return ''; + return `
`; +} + +async function ffTrackOn(columnId, key) { + if (!confirm(`${key} 스트림 추종을 켭니다 — 권장 WSP를 연속으로 컨트롤러에 씁니다. 시작?`)) return; + try { await ffApi('POST', `/api/ff/track/${columnId}/${encodeURIComponent(key)}/on`); ffLoadDash(); } + catch (e) { alert('추종 ON 실패: ' + e.message); } +} +async function ffTrackOff(columnId, key) { + try { await ffApi('POST', `/api/ff/track/${columnId}/${encodeURIComponent(key)}/off`); ffLoadDash(); } + catch (e) { alert('추종 OFF 실패: ' + e.message); } +} + +// FEED 램프 상태 줄 (활성 Job 있을 때) +function ffRampLine(rj) { + const st = rj.state; + const cls = st === 'Hold' ? 'ff-mode-arm' : st === 'Reached' ? 'ff-mode-rec' + : st === 'Canceled' ? '' : 'ff-mode-ret'; + const prog = rj.lastWrittenSp != null + ? `FEED SP ${fmtVal(rj.lastWrittenSp)} → ${fmtVal(rj.targetFeed)}` + : `목표 ${fmtVal(rj.targetFeed)}`; + const ceil = rj.ceiling != null ? ` (ceiling ${fmtVal(rj.ceiling)})` : ''; + const hold = rj.hold ? ` ⚠ ${esc(rj.hold)}` : ''; + const dry = rj.dryRun ? ' [모의]' : ''; + const cancelBtn = (st === 'Ramping' || st === 'Hold') + ? `` : ''; + return `
FEED 램프 ${esc(st)}${dry} ${prog}${ceil}${hold} ${cancelBtn}
`; +} +function ffRampCancel(id) { + if (!confirm(`컬럼 ${id} FEED 램프를 취소(현재 SP 유지)하시겠습니까?`)) return; + ffApi('POST', `/api/ff/feed-ramp/${id}/cancel`).then(() => ffLoadDash()).catch(e => alert(e.message)); +} +// 카드의 FEED Target SP로 램프 시작 +function ffCardRampStart(colId) { + const inp = document.querySelector(`.ff-rt[data-col="${colId}"]`); + const target = inp ? +inp.value : NaN; + if (!Number.isFinite(target)) { alert('FEED Target SP를 입력하세요'); return; } + const mode = ffRampDryRun ? '모의(DryRun — 실제 쓰기 없음)' : '실쓰기'; + if (!confirm(`컬럼 ${colId} FEED를 ${target}까지 램프율로 점진 상승 [${mode}]. 시작?`)) return; + ffApi('POST', `/api/ff/feed-ramp/${colId}/start`, { targetFeed: target }) + .then(() => ffLoadDash()).catch(e => alert('램프 시작 실패: ' + e.message)); +} + function ffCard(c) { const rows = (c.streams || []).map(s => { const lvlTag = s.levelTag || ''; @@ -175,7 +259,7 @@ function ffCard(c) { ${esc(s.key)}${esc(s.flowTag)} ${roleLabel} ${fmtVal(s.pv)} - ${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)} + ${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}${ffTrackCtl(c,s)} ${s.gap==null?'–':fmtVal(s.gap)} ${ffTrendIco(s.trend)} ${esc(s.grade)}${s.kObsSuggest!=null ? `
K~${fmtVal(s.kObsSuggest)}` : ''}${writeInfo} @@ -236,6 +320,14 @@ function ffCard(c) { : ''; const modeLine = (modeBadge || c.modeReason) ? `
${modeBadge} ${esc(c.modeReason||'')} ${recoveryCtl}
` : ''; + const rampJob = ffRampJobs[c.columnId]; + const rampActive = rampJob && (rampJob.state === 'Ramping' || rampJob.state === 'Hold'); + const rampLine = rampJob ? ffRampLine(rampJob) : ''; + const rampCtl = rampActive ? '' : `
FEED Target SP + + + ${ffRampDryRun ? '[모의]' : '[실쓰기]'}
`; const writeBadge = c.autoWriteActive ? '자동 SP 쓰기' : ''; const wgBlocked = c.writeGuardBlockedSp != null ? `
쓰기 차단: ${esc(c.writeGuardReason)} (SP ${fmtVal(c.writeGuardBlockedSp)})
` @@ -247,6 +339,8 @@ function ffCard(c) { ${writeBadge} ${fmtTs(c.computedAt)} ${modeLine} + ${rampLine} + ${rampCtl} ${banner} ${wgBlocked} @@ -301,7 +395,8 @@ function ffEditColumn(c) { modal.className = 'ff-modal'; const def = isNew - ? { name:'', enabled:false, feedTag:'', pressureTag:'', + ? { name:'', enabled:false, controllerId:'C1', feedTag:'', pressureTag:'', + feedSpNodeId:'', feedSpMin:0, feedSpMax:1e9, scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P', // WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스 @@ -316,16 +411,22 @@ function ffEditColumn(c) { {key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0,spNodeId:''} ] } : { ...c, pressureTag: c.pressureTag||'', + controllerId: c.controllerId||'C1', feedSpNodeId: c.feedSpNodeId||'', + feedSpMin: c.feedSpMin==null?0:c.feedSpMin, feedSpMax: c.feedSpMax==null?1e9:c.feedSpMax, tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' }; const colHtml = `
- + +
FEED 램프 실행 (작업 B)
+ + +
@@ -374,7 +475,7 @@ function ffEditColumn(c) {
- + ${def.streams.map((s,i) => ffStreamRow(s,i)).join('')} @@ -431,7 +532,7 @@ function ffStreamRow(s, i) { - + `; @@ -449,6 +550,9 @@ function ffSaveForm(existingId) { name: g('ff-f-name').value, enabled: g('ff-f-enabled').checked, feedTag: g('ff-f-feedTag').value, + feedSpNodeId: g('ff-f-feedSpNodeId').value || null, + feedSpMin: +g('ff-f-feedSpMin').value, + feedSpMax: +g('ff-f-feedSpMax').value, pressureTag: g('ff-f-pressureTag').value || null, scanSec: +g('ff-f-scanSec').value, feedFilterTauSec: +g('ff-f-feedFilterTauSec').value, diff --git a/src/Hc900Crawler/wwwroot/js/llmchat.js b/src/Hc900Crawler/wwwroot/js/llmchat.js index e838a3a..5c032c4 100644 --- a/src/Hc900Crawler/wwwroot/js/llmchat.js +++ b/src/Hc900Crawler/wwwroot/js/llmchat.js @@ -8,10 +8,6 @@ let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; let llmAbortController = null; let llmIsStreaming = false; let llmType = localStorage.getItem('llmType') || 'vllm'; -if (llmType === 'ollama') { - llmType = 'vllm'; - localStorage.setItem('llmType', 'vllm'); -} let llmUseTools = localStorage.getItem('llmUseTools') === 'true'; let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true'; let llmMcpTools = []; @@ -518,7 +514,7 @@ async function llmLoadModels() { // vLLM: llm-model.json 을 항상 단일 선택 기준으로 반영 if (llmType === 'vllm') { try { - const cfg = await api('GET', '/api/llm/config'); + const cfg = await api('GET', '/api/ollama/config'); if (cfg.success && cfg.vllmModel) { if (![...sel.options].some(o => o.value === cfg.vllmModel)) { const opt = document.createElement('option'); @@ -531,6 +527,11 @@ async function llmLoadModels() { } catch (_) {} } + // 폴백: 선택된 모델이 없으면 첫 번째 모델 자동 선택 + if (!sel.value && sel.options.length > 1) { + sel.value = sel.options[1].value; + } + const dot = document.getElementById('llm-conn-status'); if (dot) { dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error'; @@ -983,11 +984,10 @@ function llmLoadConfigToUI() { } }).catch(() => {}); - api('GET', '/api/llm/config').then(d => { + api('GET', '/api/ollama/config').then(d => { if (d.success && d.vllmModel) { const sel = document.getElementById('llm-model-select'); if (!sel) return; - // llm-model.json 값이 드롭다운 옵션에 없으면 직접 추가 if (![...sel.options].some(o => o.value === d.vllmModel)) { const opt = document.createElement('option'); opt.value = d.vllmModel; diff --git a/src/Hc900Crawler/wwwroot/js/pb.js b/src/Hc900Crawler/wwwroot/js/pb.js index 1e4869e..57ed24c 100644 --- a/src/Hc900Crawler/wwwroot/js/pb.js +++ b/src/Hc900Crawler/wwwroot/js/pb.js @@ -60,7 +60,8 @@ function pbBuildUrl() { if (p) url += `paramType=${encodeURIComponent(p)}&`; if (l) url += `loopNo=${l}&`; if (c) url += `controllerId=${encodeURIComponent(c)}&`; - if (a) url += `active=${a}&`; + if (a === 'live') url += `hasLive=true&`; + else if (a) url += `active=${a}&`; return url; } diff --git a/src/Hc900Crawler/wwwroot/js/steam.js b/src/Hc900Crawler/wwwroot/js/steam.js new file mode 100644 index 0000000..f50fe7d --- /dev/null +++ b/src/Hc900Crawler/wwwroot/js/steam.js @@ -0,0 +1,488 @@ +/* steam.js — Steam Advisory 대시보드 (라이브 uPlot + 백테스트 ECharts) */ +paneInit.steam = stInit; + +let stTimer = null; +let stLiveData = []; // {ts, recOp, actOp} history for uPlot +const ST_MAX_POINTS = 360; // 30min at 5s intervals +let stUplot = null; + +/* ── Initialization ── */ +async function stInit() { + try { + if (stTimer) { clearInterval(stTimer); stTimer = null; } + + // 탭 전환 + document.querySelectorAll('.steam-tab').forEach(tab => { + tab.onclick = () => { + document.querySelectorAll('.steam-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.st-pane').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + const pane = document.getElementById('st-' + tab.dataset.stTab); + if (pane) pane.classList.add('active'); + if (tab.dataset.stTab === 'backtest') stBtInit(); + else if (tab.dataset.stTab === 'temp') stTempInit(); + }; + }); + + // 컬럼 선택 로드 + await stLoadColumns(); + + // 라이브 — 조회 버튼으로 시작 + document.getElementById('st-col').onchange = () => { stLiveData = []; if (stTimer) { clearInterval(stTimer); stTimer = null; } }; + document.getElementById('st-live-load').onclick = stLiveStart; + + // 백테스트 서브탭 + document.querySelectorAll('.st-bt-tab').forEach(tab => { + tab.onclick = () => { + document.querySelectorAll('.st-bt-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.st-bt-pane').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + const name = tab.dataset.stBt; + const pane = document.getElementById('st-bt-' + name); + if (pane) pane.classList.add('active'); + if (stBtData && !stRendered.has(name)) { + stRendered.add(name); + if (name === 'startup') stRenderStartup(stBtData); + else if (name === 'shadow') stRenderShadow(stBtData, 'shadow'); + else if (name === 'advisory') stRenderShadow(stBtData, 'advisory'); + } + }; + }); + document.getElementById('st-bt-load').onclick = () => stBtLoad(); + } catch (e) { + console.warn('[steam] init fail:', e); + const msg = document.getElementById('st-live-msg'); + if (msg) msg.textContent = '초기화 오류: ' + e.message; + } +} + +/* ── Column list ── */ +async function stLoadColumns() { + try { + const d = await api('GET', '/api/steam/models'); + const sel1 = document.getElementById('st-col'); + const sel2 = document.getElementById('st-bt-col'); + const cols = d.configured || d.columns || []; + [sel1, sel2].forEach(sel => { + sel.innerHTML = cols.map(c => ``).join(''); + }); + } catch (_) {} +} + +/* ── 온도 프로파일 이격 모니터 ── */ +let stTempTimer = null; +const ST_TEMP_COLS = [['61','6-1차'],['62','6-2차'],['81','8차'],['91','9-1차'],['92','9-2차'],['101','10-1차'],['102','10-2차']]; +const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' }; +const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1); + +function stTempInit() { + const sel = document.getElementById('st-temp-col'); + if (sel && !sel.options.length) + sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => ``).join(''); + document.getElementById('st-temp-load').onclick = stTempLoad; +} + +async function stTempLoad() { + if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; } + await stTempTick(); + stTempTimer = setInterval(stTempTick, 5000); +} + +async function stTempTick() { + const col = document.getElementById('st-temp-col').value; + const st = document.getElementById('st-temp-status'); + try { + const d = await api('GET', `/api/steam/tempprofile/${col}`); + stRenderTemp(d); + st.textContent = '갱신: ' + new Date().toLocaleTimeString(); + } catch (e) { + st.textContent = '오류: ' + e.message; + } +} + +function stRenderTemp(d) { + // 제품/진공 배지 + const pb = document.getElementById('st-temp-product'); + pb.textContent = '제품 ' + (d.matchedProduct || '—'); + const vb = document.getElementById('st-temp-vac'); + vb.textContent = `진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})` + (d.vacuum.deviated ? ' ⚠이격' : ''); + vb.style.background = d.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; + vb.style.color = d.vacuum.deviated ? '#f66' : '#66f'; + + const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage); + const lo = d.stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null); + const band = d.stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null); + const med = d.stages.map(s => s.refMedian); + const cur = d.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } })); + + const el = document.getElementById('st-chart-temp'); + const chart = echarts.getInstanceByDom(el) || echarts.init(el); + chart.setOption({ + backgroundColor: 'transparent', + title: { text: `${d.column} 단면 온도 프로파일`, subtext: `매칭제품 ${d.matchedProduct || '—'} · 진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})${d.vacuum.deviated ? ' ⚠' : ''}`, + textStyle: { color: '#ccc', fontSize: 13 }, subtextStyle: { color: d.vacuum.deviated ? '#f66' : '#888', fontSize: 11 } }, + tooltip: { trigger: 'axis' }, + legend: { data: ['기준밴드', '기준', '현재'], textStyle: { color: '#888' }, top: 6, right: 10 }, + grid: { top: 64, left: 48, right: 20, bottom: 28 }, + xAxis: { type: 'category', data: cats, axisLabel: { color: '#aaa' } }, + yAxis: { type: 'value', name: '℃', scale: true, axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#1a2a3a' } } }, + series: [ + { name: '_lo', type: 'line', data: lo, lineStyle: { opacity: 0 }, stack: 'band', symbol: 'none', silent: true }, + { name: '기준밴드', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true }, + { name: '기준', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' }, + { name: '현재', type: 'line', data: cur, lineStyle: { color: '#6cf', width: 2 }, symbolSize: 9 }, + ], + }); + chart.resize(); + + // 상세 메타 + const meta = document.getElementById('st-temp-meta'); + meta.style.display = 'block'; + const lines = d.stages.map(s => + `${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ${stFmt(s.refMedian)}±${stFmt(s.refStd)} z=${s.z != null ? s.z.toFixed(1) : '—'}${s.deviated ? ' ⚠이격' : ''}`); + lines.push(`ΔT(A-D) ${stFmt(d.spanAD)}℃ 기준 ${stFmt(d.spanRef)}`); + lines.push(`진공 ${stFmt(d.vacuum.current)} 기준 ${stFmt(d.vacuum.refMedian)}±${stFmt(d.vacuum.refStd)} z=${d.vacuum.z != null ? d.vacuum.z.toFixed(1) : '—'}${d.vacuum.deviated ? ' ⚠이격' : ''}`); + meta.textContent = lines.join('\n') + `\n\n기간 ${d.period} · 제품 ${d.nProducts}종`; +} + +/* ── Live panel ── */ +async function stLiveStart() { + try { + if (stTimer) { clearInterval(stTimer); stTimer = null; } + stLiveData = []; + stInitUplot(); + await stLiveTick(); + stTimer = setInterval(stLiveTick, 5000); + document.getElementById('st-live-msg').textContent = ''; + } catch (e) { + document.getElementById('st-live-msg').textContent = '오류: ' + e.message; + console.warn('[steam] live start fail:', e); + } +} + +async function stLiveTick() { + const col = document.getElementById('st-col').value; + try { + const d = await api('GET', `/api/steam/live?col=${col}`); + stUpdateLive(d); + } catch (_) {} +} + +function stUpdateLive(d) { + document.getElementById('st-mode').textContent = d.mode || '—'; + document.getElementById('st-conf').textContent = d.confidence || '—'; + const oodEl = document.getElementById('st-ood'); + if (d.ood) { oodEl.style.display = 'inline-block'; } else { oodEl.style.display = 'none'; } + + document.getElementById('st-op-rec').textContent = d.recOp != null ? d.recOp.toFixed(1) : '—'; + document.getElementById('st-op-act').textContent = d.actualOp != null ? d.actualOp.toFixed(1) : '—'; + const opTag = document.getElementById('st-op-act-tag'); + if (opTag) opTag.textContent = (d.Tags?.steamOp || '') + (d.Descs?.steamOp ? ' · ' + d.Descs.steamOp : ''); + + // 게이지 + const fill = document.getElementById('st-gauge-fill'); + if (d.recOp != null) fill.style.width = Math.min(100, Math.max(0, d.recOp)) + '%'; + + // 입력값 + 태그명 + 설명 + document.getElementById('st-live-feed').textContent = d.feed != null ? d.feed.toFixed(1) : '—'; + document.getElementById('st-live-feed-tag').textContent = (d.Tags?.feed || '') + (d.Descs?.feed ? ' · ' + d.Descs.feed : ''); + document.getElementById('st-live-product').textContent = d.product != null ? d.product.toFixed(1) : '—'; + document.getElementById('st-live-product-tag').textContent = (d.Tags?.product || '') + (d.Descs?.product ? ' · ' + d.Descs.product : ''); + document.getElementById('st-live-tc').textContent = d.tC != null ? d.tC.toFixed(1) : '—'; + document.getElementById('st-live-tc-tag').textContent = (d.Tags?.tC || '') + (d.Descs?.tC ? ' · ' + d.Descs.tC : ''); + document.getElementById('st-live-sf').textContent = d.actualSteamFlow != null ? d.actualSteamFlow.toFixed(1) : '—'; + document.getElementById('st-live-sf-tag').textContent = (d.Tags?.steamFlow || '') + (d.Descs?.steamFlow ? ' · ' + d.Descs.steamFlow : ''); + + document.getElementById('st-live-msg').textContent = d.message || ''; + + // uPlot data + const now = Date.now() / 1000; + stLiveData.push({ ts: now, recOp: d.recOp, actOp: d.actualOp }); + if (stLiveData.length > ST_MAX_POINTS) stLiveData.splice(0, stLiveData.length - ST_MAX_POINTS); + stUpdateUplot(); +} + +/* ── uPlot OP chart ── */ +function stInitUplot() { + const opts = { + width: document.getElementById('st-op-chart').clientWidth || 600, + height: 240, + cursor: { show: true }, + legend: { show: true }, + series: [ + { label: 'time' }, + { label: '권장 OP', stroke: '#4af', width: 2, points: { show: false } }, + { label: '실제 OP', stroke: '#fa3', width: 1.5, points: { show: false }, spanGaps: true }, + ], + axes: [ + { stroke: '#555', grid: { stroke: '#1a2a3a' } }, + { stroke: '#555', grid: { stroke: '#1a2a3a' }, range: [0, 100] }, + ], + }; + const data = [[], [], []]; + if (stUplot) try { stUplot.destroy(); } catch(_) {} + stUplot = new uPlot(opts, data, document.getElementById('st-op-chart')); +} + +function stUpdateUplot() { + if (!stUplot || stLiveData.length < 2) return; + const times = stLiveData.map(d => d.ts); + const recOps = stLiveData.map(d => d.recOp != null ? d.recOp : null); + const actOps = stLiveData.map(d => d.actOp != null ? d.actOp : null); + stUplot.setData([times, recOps, actOps]); +} + +/* ── Backtest panel ── */ +let stBtData = null; +const stRendered = new Set(); + +async function stBtInit() { + if (stBtData) return; + const sel = document.getElementById('st-bt-col'); + if (sel.options.length === 0) await stLoadColumns(); +} + +async function stBtLoad() { + const col = document.getElementById('st-bt-col').value; + const status = document.getElementById('st-bt-status'); + if (!col) return; + status.textContent = '로딩 중…'; + try { + const d = await api('GET', `/api/steam/backtest/${col}`); + stBtData = d; + stRendered.clear(); + status.textContent = `PROD ${(d.prodmap?.n_prod_rows || 0).toLocaleString()}행`; + stRenderProdmap(d); + stRendered.add('prodmap'); + // 첫 서브탭 활성화 + document.querySelector('.st-bt-tab.active')?.click(); + } catch (ex) { + status.textContent = '로드 실패: ' + ex.message; + } +} + +/* ── 헬퍼: axisPointer 가시화 ── */ +const _stTip = { + trigger: 'axis', + axisPointer: { type: 'cross', crossStyle: { color: '#888', width: 1, type: 'dashed' } }, +}; + +/* ── Prodmap charts ── */ +function stRenderProdmap(d) { + const renderOne = (id, fn) => { + try { + const el = document.getElementById(id); + if (!el) return; + fn(echarts.init(el)); + } catch (e) { console.warn('[steam] chart fail:', id, e); } + }; + + renderOne('st-chart-valve', chart => { + const vc = d.prodmap?.valve_char; + if (!vc) return; + chart.setOption({ + title: { text: 'Valve Characteristic', textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: _stTip, + legend: { textStyle: { color: '#888' }, bottom: 0 }, + xAxis: { type: 'value', name: 'OP %', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', name: 'steam flow', axisLabel: { color: '#888' } }, + grid: { containLabel: true, left: 50, right: 10, top: 30, bottom: 40 }, + series: [ + { name: '평균', type: 'line', data: vc.map(r => [r.op, r.flow_mean]), smooth: true, + lineStyle: { color: '#4af', width: 2 }, symbol: 'circle', symbolSize: 4 }, + { name: 'OP 상승', type: 'line', data: vc.filter(r => r.flow_up != null).map(r => [r.op, r.flow_up]), + lineStyle: { color: '#6a6', width: 1, type: 'dashed' }, symbol: 'none' }, + { name: 'OP 하강', type: 'line', data: vc.filter(r => r.flow_dn != null).map(r => [r.op, r.flow_dn]), + lineStyle: { color: '#c66', width: 1, type: 'dashed' }, symbol: 'none' }, + ], + backgroundColor: 'transparent', + }); + chart.resize(); + }); + + renderOne('st-chart-op-points', chart => { + const op = d.prodmap?.operating_points; + if (!op || !op.feed || !op.steam_flow) return; + const data = op.feed.map((f, i) => [f, op.steam_flow[i]]).filter(p => p[0] != null && p[1] != null); + if (!data.length) return; + chart.setOption({ + title: { text: 'Operating Points', textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross', lineStyle: { color: '#888', width: 1, type: 'dashed' } }, formatter: p => `Feed: ${p[0].value[0].toFixed(0)}
Steam: ${p[0].value[1].toFixed(0)}` }, + xAxis: { type: 'value', name: 'feed', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', name: 'steam flow', axisLabel: { color: '#888' } }, + grid: { containLabel: true, left: 55, right: 10, top: 30, bottom: 20 }, + series: [{ type: 'scatter', data, symbolSize: 6, itemStyle: { color: '#4af' } }], + backgroundColor: 'transparent', + }); + chart.resize(); + }); + + renderOne('st-chart-pred-vs-act', chart => { + const pv = d.prodmap?.pred_vs_actual; + if (!pv || !pv.actual || !pv.predicted) return; + const data = pv.actual.map((a, i) => [a, pv.predicted[i]]).filter(p => p[0] != null && p[1] != null); + if (!data.length) return; + const allVals = data.flat(); + const maxVal = Math.max(...allVals); + const minVal = Math.min(...allVals); + chart.setOption({ + title: { text: `Pred vs Actual (R²=${pv.r2})`, textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross', lineStyle: { color: '#888', width: 1, type: 'dashed' } }, formatter: p => `Actual: ${p[0].value[0].toFixed(0)}
Pred: ${p[0].value[1].toFixed(0)}` }, + xAxis: { type: 'value', name: 'actual', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', name: 'predicted', axisLabel: { color: '#888' } }, + grid: { containLabel: true, left: 55, right: 10, top: 30, bottom: 20 }, + series: [ + { type: 'scatter', data, symbolSize: 5, itemStyle: { color: '#4af' } }, + { type: 'line', data: [[minVal, minVal], [maxVal, maxVal]], lineStyle: { color: '#f44', width: 1, type: 'dashed' }, symbol: 'none' }, + ], + backgroundColor: 'transparent', + }); + chart.resize(); + }); + + renderOne('st-chart-feat-imp', chart => { + const fi = d.prodmap?.feature_importance; + if (!fi || !fi.feature || !fi.gbm_importance) return; + const feats = fi.feature.slice().reverse(); + const vals = fi.gbm_importance.slice().reverse(); + chart.setOption({ + title: { text: 'Feature Importance (GBM)', textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: _stTip, + xAxis: { type: 'value', axisLabel: { color: '#888' } }, + yAxis: { type: 'category', data: feats, axisLabel: { color: '#aaa' } }, + grid: { containLabel: true, left: 80, right: 10, top: 30, bottom: 10 }, + series: [{ type: 'bar', data: vals, itemStyle: { color: '#6af' } }], + backgroundColor: 'transparent', + }); + chart.resize(); + }); +} + +/* ── Startup chart ── */ +function stRenderStartup(d) { + try { + const st = d.startup; + const ep = st?.episodes; + const el = document.getElementById('st-chart-startup'); + if (!ep || ep.length === 0) { + if (el) el.innerHTML = '
Startup 데이터 없음
'; + return; + } + const chart = echarts.init(el); + const colors = ['#4af', '#fa3', '#6a6', '#c66', '#a6d', '#6cc', '#cc6', '#f6a', '#6fa', '#a6f']; + const series = []; + ep.forEach((e, k) => { + const c = colors[k % colors.length]; + if (!e.rel_min || !e.reb_temp || !e.steam_flow) return; + series.push( + { name: `reb_temp ep${k+1}`, type: 'line', data: e.rel_min.map((t, i) => [t, e.reb_temp[i]]).filter(p => p[0] != null && p[1] != null), + lineStyle: { color: c, width: 1.5 }, symbol: 'none' }, + { name: `steam ep${k+1}`, type: 'line', data: e.rel_min.map((t, i) => [t, e.steam_flow[i]]).filter(p => p[0] != null && p[1] != null), + lineStyle: { color: c, width: 1, type: 'dashed' }, symbol: 'none' }, + ); + }); + if (!series.length) return; + chart.setOption({ + title: { text: `Startup Aligned (${ep.length} episodes)`, textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: _stTip, + legend: { type: 'scroll', textStyle: { color: '#888', fontSize: 10 }, top: 24 }, + xAxis: { type: 'value', name: 'minutes from cut-in', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', axisLabel: { color: '#888' } }, + grid: { containLabel: true, left: 50, right: 10, top: 50, bottom: 20 }, + series, + backgroundColor: 'transparent', + }); + chart.resize(); + } catch (e) { console.warn('[steam] startup chart fail:', e); } +} + +/* ── Shadow / Advisory chart ── */ +function stRenderShadow(d, key) { + try { + const section = d[key]; + if (!section) return; + const ts = section.time_series; + const hist = section.error_histogram; + const chartId = key === 'advisory' ? 'st-chart-advisory' : 'st-chart-shadow'; + const histId = key === 'advisory' ? 'st-chart-advisory-hist' : 'st-chart-shadow-hist'; + + const el = document.getElementById(chartId); + if (!el) return; + if (!ts || !ts.time || !ts.actual_op || !ts.predicted_op || ts.n < 2) { + el.innerHTML = '
데이터 없음
'; + return; + } + + const time = ts.time.map(t => new Date(t).getTime()); + const chart = echarts.init(el); + + // NaN/null 필터링 + const actData = []; + const predData = []; + const oodFlags = ts.ood || []; + for (let i = 0; i < time.length; i++) { + const t = time[i]; + const a = ts.actual_op[i]; + const p = ts.predicted_op[i]; + if (a != null && isFinite(a)) actData.push([t, a]); + if (p != null && isFinite(p)) predData.push([t, p]); + } + + // OOD 영역 + const oodAreas = []; + let oodStart = null; + for (let i = 0; i < oodFlags.length; i++) { + if (oodFlags[i] && oodStart === null) oodStart = time[i]; + if (!oodFlags[i] && oodStart !== null) { + oodAreas.push([{ xAxis: oodStart }, { xAxis: time[i] }]); + oodStart = null; + } + } + if (oodStart !== null) oodAreas.push([{ xAxis: oodStart }, { xAxis: time[time.length - 1] }]); + + chart.setOption({ + title: { text: `${key === 'advisory' ? 'Advisory' : 'Shadow'} OP: actual vs predicted`, + textStyle: { color: '#aaa', fontSize: 12 } }, + tooltip: _stTip, + legend: { textStyle: { color: '#888' }, top: 24 }, + xAxis: { type: 'time', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', name: 'OP %', axisLabel: { color: '#888' }, min: 0, max: 100 }, + grid: { containLabel: true, left: 45, right: 10, top: 50, bottom: 20 }, + series: [ + { name: 'actual OP', type: 'line', data: actData, + lineStyle: { color: '#fa3', width: 1 }, symbol: 'none' }, + { name: 'predicted OP', type: 'line', data: predData, + lineStyle: { color: '#4af', width: 1.5 }, symbol: 'none' }, + ...oodAreas.map(area => ({ + type: 'line', markArea: { silent: true, data: [area] }, + lineStyle: { color: 'transparent' }, + })), + ], + backgroundColor: 'transparent', + }); + chart.resize(); + + // 히스토그램 + if (hist && hist.bin_edges && hist.counts) { + const hel = document.getElementById(histId); + if (hel) { + const hc = echarts.init(hel); + const bins = hist.bin_edges; + const cents = bins.slice(0, -1).map((v, i) => (v + bins[i + 1]) / 2); + hc.setOption({ + title: { text: 'Error distribution', textStyle: { color: '#aaa', fontSize: 11 } }, + tooltip: _stTip, + xAxis: { type: 'value', name: 'OP error', axisLabel: { color: '#888' } }, + yAxis: { type: 'value', axisLabel: { color: '#888' } }, + grid: { containLabel: true, left: 45, right: 10, top: 30, bottom: 20 }, + series: [{ + type: 'bar', data: cents.map((c, i) => [c, hist.counts[i]]), + barWidth: '90%', itemStyle: { color: '#4af' }, + }], + backgroundColor: 'transparent', + }); + hc.resize(); + } + } + } catch (e) { console.warn('[steam]', key, 'chart fail:', e); } +} diff --git a/src/Hc900Crawler/wwwroot/js/trend.js b/src/Hc900Crawler/wwwroot/js/trend.js index 5ddf39c..6c32ed2 100644 --- a/src/Hc900Crawler/wwwroot/js/trend.js +++ b/src/Hc900Crawler/wwwroot/js/trend.js @@ -331,14 +331,18 @@ async function trQuery() { } // 원시 조회 (집계 실패/0행 시 폴백으로도 사용) + // /api/history/query 는 [HttpPost] + [FromBody] 이고 응답은 PascalCase(Rows/RecordedAt/Values)다. const rawQuery = async () => { - const p = new URLSearchParams(); - tags.forEach(t => p.append('tagNames', t)); - if (from) p.set('from', new Date(from).toISOString()); - if (to) p.set('to', new Date(to).toISOString()); - p.set('limit', '5000'); - const d = await api('GET', `/api/history/query?${p}`); - return { rows: d.rows || [], tk: 'recordedAt' }; + const body = { tagNames: tags, limit: 5000 }; + if (from) body.from = new Date(from).toISOString(); + if (to) body.to = new Date(to).toISOString(); + const d = await api('POST', '/api/history/query', body); + const raw = d.rows || d.Rows || []; + const rows = raw.map(r => ({ + recordedAt: r.recordedAt ?? r.RecordedAt, + values: r.values ?? r.Values + })); + return { rows, tk: 'recordedAt' }; }; let rows = [], tk = 'recordedAt'; diff --git a/src/Hc900Crawler/wwwroot/panes/ff.html b/src/Hc900Crawler/wwwroot/panes/ff.html index cb92562..6f04906 100644 --- a/src/Hc900Crawler/wwwroot/panes/ff.html +++ b/src/Hc900Crawler/wwwroot/panes/ff.html @@ -1,7 +1,7 @@

측류추출 권장 유량 설정값 (Advisory · 보조지표)

- 읽기 전용 — 권장값. 인가는 운전원 + 권장값 · 버튼으로 인가 시 컨트롤러 SP 쓰기 — 인가는 운전원
@@ -18,6 +18,8 @@ + +
diff --git a/src/Hc900Crawler/wwwroot/panes/pb.html b/src/Hc900Crawler/wwwroot/panes/pb.html index b33d466..c2c0605 100644 --- a/src/Hc900Crawler/wwwroot/panes/pb.html +++ b/src/Hc900Crawler/wwwroot/panes/pb.html @@ -50,6 +50,7 @@ + diff --git a/src/Hc900Crawler/wwwroot/panes/steam.html b/src/Hc900Crawler/wwwroot/panes/steam.html new file mode 100644 index 0000000..0ce6ee5 --- /dev/null +++ b/src/Hc900Crawler/wwwroot/panes/steam.html @@ -0,0 +1,162 @@ +
+
+

Steam Advisory — 오퍼레이터 보조

+ advisory-only · write 금지 +
+ + +
+ + + +
+ + +
+ + +
+
+ 컬럼: + + + + +
+
+ OP: % 권장 + | + 실제 OP: % +
+
+ + +
+
+
+
+
+
+ 0%25%50%75%100% +
+
+
+ + +
OP 추이 최근 30분
+
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+ 컬럼: + + 제품 — + 진공 — + +
+
단별 온도(reb-A>T_B>T_C>T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)
+
+ +
+ + +
+
+ 컬럼: + + + +
+ + +
+ + + + +
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+ + + diff --git a/src/Infrastructure/Control/FeedRampAdvisorService.cs b/src/Infrastructure/Control/FeedRampAdvisorService.cs index b743032..b7d9b5d 100644 --- a/src/Infrastructure/Control/FeedRampAdvisorService.cs +++ b/src/Infrastructure/Control/FeedRampAdvisorService.cs @@ -33,8 +33,11 @@ public sealed class FeedRampAdvisorService var tags = new List { PvTag(cfg.FeedTag), picaTag, piTag, tiTag, steamTag, opTag }; tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); + // DB 조회는 대소문자 무관이지만 반환 TagName은 저장 케이스(예: FICQ-6101.PV). + // TryRead가 만든 태그(소문자 .pv 포함)로 조회하므로 딕셔너리도 대소문자 무관이어야 매칭됨. var rows = (await _db.GetRealtimeRecordsByTagNamesAsync(tags)) - .ToDictionary(r => r.TagName, r => r); + .GroupBy(r => r.TagName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); bool TryRead(string tag, out double v) { diff --git a/src/Infrastructure/Control/FeedRampCalculator.cs b/src/Infrastructure/Control/FeedRampCalculator.cs index 02d6037..93cecea 100644 --- a/src/Infrastructure/Control/FeedRampCalculator.cs +++ b/src/Infrastructure/Control/FeedRampCalculator.cs @@ -53,39 +53,70 @@ public static class FeedRampCalculator else { ceiling = valveCeiling; ceilBind = valveBind; } if (double.IsPositiveInfinity(ceiling)) warnings.Add("ceiling 무제한(밸브 sp_max 부재)"); - // 3) Ramp rate (kg/hr per min) - double valveSlew = double.PositiveInfinity; string slewBind = "valveSlew"; + // 3) 방향 판정 + bool goingUp = targetFeed >= currentFeed; + + // 4) Ramp rate (kg/hr per min) — 방향별 rate 선택 + double valveSlewUp = double.PositiveInfinity; + double valveSlewDn = double.PositiveInfinity; + string slewBindUp = "valveSlewUp", slewBindDn = "valveSlewDn"; foreach (var s in cfg.Streams) { - if (s.Role != StreamRole.Commanded || s.RateUpPerMin <= 0 || s.TargetCoeff <= 0) continue; - double r = s.RateUpPerMin / s.TargetCoeff; - if (r < valveSlew) { valveSlew = r; slewBind = $"valveSlew@{s.Key}"; } + if (s.Role != StreamRole.Commanded || s.TargetCoeff <= 0) continue; + if (s.RateUpPerMin > 0) { double r = s.RateUpPerMin / s.TargetCoeff; if (r < valveSlewUp) { valveSlewUp = r; slewBindUp = $"valveSlewUp@{s.Key}"; } } + if (s.RateDnPerMin > 0) { double r = s.RateDnPerMin / s.TargetCoeff; if (r < valveSlewDn) { valveSlewDn = r; slewBindDn = $"valveSlewDn@{s.Key}"; } } } - // dynamic: lagging Commanded 합 ΣK_i(τ_i+θ_up_i) (C-6111은 P만 τ≠0) - double denom = 0; + // dynamic 제약: 상승/하강 각각의 θ 사용 + double denomUp = 0, denomDn = 0; foreach (var s in cfg.Streams) - if (s.Role == StreamRole.Commanded) denom += s.TargetCoeff * (s.TauSec + s.ThetaUpSec); - double dynamic = double.PositiveInfinity; - if (denom > 0 && deltaIAllow > 0) dynamic = deltaIAllow * 60.0 / denom; - else warnings.Add("dynamic 제약 미산정(lag 0 또는 ΔI≤0)"); + { + if (s.Role != StreamRole.Commanded) continue; + denomUp += s.TargetCoeff * (s.TauSec + s.ThetaUpSec); + denomDn += s.TargetCoeff * (s.TauSec + s.ThetaDnSec); + } + double dynamicUp = double.PositiveInfinity, dynamicDn = double.PositiveInfinity; + if (denomUp > 0 && deltaIAllow > 0) dynamicUp = deltaIAllow * 60.0 / denomUp; + if (denomDn > 0 && deltaIAllow > 0) dynamicDn = deltaIAllow * 60.0 / denomDn; + if (denomUp <= 0 || deltaIAllow <= 0) warnings.Add("dynamic-up 제약 미산정(lag 0 또는 ΔI≤0)"); + if (denomDn <= 0 || deltaIAllow <= 0) warnings.Add("dynamic-dn 제약 미산정(lag 0 또는 ΔI≤0)"); warnings.Add("energyLoop 제약 미산정(에너지루프 시상수 부재)"); double rFeed; string rateBind; - if (valveSlew <= dynamic) { rFeed = valveSlew; rateBind = slewBind; } - else { rFeed = dynamic; rateBind = "dynamic"; } + if (goingUp) + { + if (valveSlewUp <= dynamicUp) { rFeed = valveSlewUp; rateBind = slewBindUp; } + else { rFeed = dynamicUp; rateBind = "dynamic-up"; } + } + else + { + if (valveSlewDn <= dynamicDn) { rFeed = valveSlewDn; rateBind = slewBindDn; } + else { rFeed = dynamicDn; rateBind = "dynamic-dn"; } + } if (double.IsPositiveInfinity(rFeed)) { rateBind = "none"; warnings.Add("램프율 무제한(제약 없음)"); } - // 4) clamp - double clampedTarget = Math.Min(targetFeed, ceiling); + // 5) clamp (상승=ceiling, 하강=floor) + double clampedTarget; + if (goingUp) + clampedTarget = Math.Min(targetFeed, ceiling); + else + { + // 하강 floor = stream sp_min 기준 + double floor = 0.0; + foreach (var s in cfg.Streams) + if (s.Role == StreamRole.Commanded && s.TargetCoeff > 0) floor = Math.Max(floor, s.SpMin / s.TargetCoeff); + clampedTarget = Math.Max(targetFeed, floor); + } - // 5) time + // 6) time double rampTimeMin = 0; - if (clampedTarget > currentFeed && Num.IsFinite(rFeed) && rFeed > 0) + if (goingUp && clampedTarget > currentFeed && Num.IsFinite(rFeed) && rFeed > 0) rampTimeMin = (clampedTarget - currentFeed) / rFeed; - else if (clampedTarget < currentFeed) - warnings.Add("down-ramp: rate_dn 분기 미구현(시간 0 표기)"); + else if (!goingUp && clampedTarget < currentFeed && Num.IsFinite(rFeed) && rFeed > 0) + rampTimeMin = (currentFeed - clampedTarget) / rFeed; + else if (!goingUp) + warnings.Add("down-ramp: clampedTarget≥currentFeed — 이미 목표 이하"); // 6) Steam target = 처리량비례 + 현열보정(앵커 대비 편차) (a안) double? steamFrom = extra.Fiq6115; diff --git a/src/Infrastructure/Control/FeedRampExecutorService.cs b/src/Infrastructure/Control/FeedRampExecutorService.cs new file mode 100644 index 0000000..935232c --- /dev/null +++ b/src/Infrastructure/Control/FeedRampExecutorService.cs @@ -0,0 +1,264 @@ +using Hc900Crawler.Core.Application.Feedforward; +using Hc900Crawler.Core.Application.Interfaces; +using Hc900Crawler.Infrastructure.Hc900; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Hc900Crawler.Infrastructure.Control; + +/// +/// 작업 B: FEED 램프 실행기. 운전원이 시작한 활성 Job에 대해 주기마다 +/// FeedRampCalculator 결과(RampRate·Ceiling)에 따라 FEED SP를 단계적으로 써 나간다. +/// 안전: 피드 불량 HOLD / 범위 클램프 / 단조진행(다운 점프 금지) / DryRun / SimOverride 억제. +/// +public sealed class FeedRampExecutorService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IFeedRampJobStore _jobs; + private readonly Hc900WriteService _writeClient; + private readonly ISimOverrideStore _sim; + private readonly IConfiguration _appConfig; + private readonly ILogger _logger; + + private const double Eps = 1e-6; + + public FeedRampExecutorService( + IServiceScopeFactory scopeFactory, IFeedRampJobStore jobs, + Hc900WriteService writeClient, ISimOverrideStore sim, + IConfiguration appConfig, ILogger logger) + { + _scopeFactory = scopeFactory; _jobs = jobs; _writeClient = writeClient; + _sim = sim; _appConfig = appConfig; _logger = logger; + } + + private int StepIntervalSec => Math.Clamp(_appConfig.GetValue("Feedforward:FeedRampStepSec") ?? 10, 2, 120); + private bool GlobalDryRun => _appConfig.GetValue("Feedforward:FeedRampDryRun") ?? true; + + protected override async Task ExecuteAsync(CancellationToken ct) + { + await Task.Yield(); + while (!ct.IsCancellationRequested) + { + try + { + var active = _jobs.GetAll() + .Where(j => j.State == FeedRampState.Ramping || j.State == FeedRampState.Hold) + .ToList(); + if (active.Count > 0) + { + using var scope = _scopeFactory.CreateScope(); + var cfgStore = scope.ServiceProvider.GetRequiredService(); + var ramp = scope.ServiceProvider.GetRequiredService(); + var audit = scope.ServiceProvider.GetService(); + var db = scope.ServiceProvider.GetRequiredService(); + var cfgs = await cfgStore.LoadAllAsync(ct); + + foreach (var job in active) + { + var cfg = cfgs.FirstOrDefault(c => c.Id == job.ColumnId); + try { await StepJobAsync(job, cfg, ramp, audit, db, ct); } + catch (Exception ex) { _logger.LogWarning(ex, "[FeedRamp] step 실패 col={Col}", job.ColumnId); } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[FeedRamp] 실행기 루프 오류"); + } + await Task.Delay(TimeSpan.FromSeconds(StepIntervalSec), ct); + } + } + + private async Task StepJobAsync(FeedRampJob job, ColumnConfig? cfg, + FeedRampAdvisorService ramp, IFeedforwardAuditService? audit, IExperionDbService db, CancellationToken ct) + { + if (cfg is null) + { + _jobs.Update(job with { State = FeedRampState.Canceled, Hold = "config 없음" }); + return; + } + // FEED SP 대상 = Feed 태그에서 WSP(.SP) 자동 파생. FeedSpNodeId는 선택적 override. + var feedSpTag = FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId); + if (string.IsNullOrWhiteSpace(feedSpTag)) + { + _jobs.Update(job with { State = FeedRampState.Canceled, Hold = "Feed 태그 없음 — SP 대상 불가" }); + return; + } + + // SimOverride 활성 시 입력이 가짜이므로 실쓰기 억제(강제 DryRun) + bool dryRun = job.DryRun || GlobalDryRun || _sim.Enabled; + + double floodLimit = (cfg.DeltaPFloodLimit < double.MaxValue && cfg.DeltaPFloodLimit > 0) + ? cfg.DeltaPFloodLimit : double.NaN; + var adv = await ramp.ComputeAsync(cfg.Id, job.TargetFeed, + deltaIAllow: 50, sensibleGain: double.NaN, feedTempRef: double.NaN, + floodLimit: floodLimit, n: 1.8, ct); + + if (adv is null) + { + _jobs.Update(job with { State = FeedRampState.Hold, Hold = "advisory 계산 불가" }); + return; + } + + // 피드 불량 → HOLD (다음 단계 안 씀, elapsed 리셋해 재개 시 점프 방지) + if (adv.Hold) + { + _jobs.Update(job with + { + State = FeedRampState.Hold, + Hold = adv.Warnings.FirstOrDefault() ?? "피드 불량", + CurrentFeed = double.IsFinite(adv.CurrentFeed) ? adv.CurrentFeed : null, + LastStepAt = DateTime.UtcNow, + Warnings = adv.Warnings + }); + return; + } + + double currentFeed = adv.CurrentFeed; + double rampRate = adv.RampRate.Value; + double ceiling = adv.Ceiling.Value; + double targetCap = Math.Min(job.TargetFeed, double.IsFinite(ceiling) ? ceiling : job.TargetFeed); + + // 앵커: 첫 단계 — LastWrittenSp를 현재 FEED WSP(설정값)로 시드(범프리스 시작). + // ※ 측정 PV(currentFeed)는 데드타임·필터 지연값이므로 앵커로 쓰지 않음. SP는 WSP에서 출발해 + // 램프율로 시간 적분한 궤적을 추종한다(PV 비의존). WSP 조회 불가 시에만 PV로 폴백. + if (double.IsNaN(job.LastWrittenSp)) + { + double? curWsp = await TryReadValueAsync(db, feedSpTag); + double anchor = curWsp ?? (double.IsFinite(currentFeed) ? currentFeed : cfg.FeedSpMin); + anchor = Math.Clamp(anchor, cfg.FeedSpMin, ClampMax(cfg.FeedSpMax)); + _jobs.Update(job with + { + LastWrittenSp = anchor, LastStepAt = DateTime.UtcNow, + CurrentFeed = double.IsFinite(currentFeed) ? currentFeed : null, + Ceiling = double.IsFinite(ceiling) ? ceiling : null, + RampRate = double.IsFinite(rampRate) ? rampRate : null, + State = FeedRampState.Ramping, Hold = null, Warnings = adv.Warnings + }); + return; + } + + // 도달 판정 + if (job.LastWrittenSp >= targetCap - Eps) + { + _jobs.Update(job with + { + State = FeedRampState.Reached, Hold = null, + CurrentFeed = double.IsFinite(currentFeed) ? currentFeed : null, + Ceiling = double.IsFinite(ceiling) ? ceiling : null, Warnings = adv.Warnings + }); + return; + } + + // 램프율 필요 — 없으면(무제한/미산정) 안전상 HOLD + if (!double.IsFinite(rampRate) || rampRate <= 0) + { + _jobs.Update(job with + { + State = FeedRampState.Hold, Hold = "램프율 미산정/무제한 — 단계 보류", + Warnings = adv.Warnings + }); + return; + } + + var now = DateTime.UtcNow; + var lastStep = job.LastStepAt ?? job.StartedAt; + double elapsedMin = (now - lastStep).TotalMinutes; + + // 레이트리밋(§3.4): 최소 ScanSec*2 간격 + double minIntervalSec = Math.Max(cfg.ScanSec * 2, 2.0); + if ((now - lastStep).TotalSeconds < minIntervalSec) return; + + // 작업플랜-민감단온도: 하강 램프 지원 (adv.RampRate 부호에 따라 방향 결정) + bool goingDown = targetCap < job.LastWrittenSp - 1e-6; + double step = rampRate * elapsedMin; + double nextSp = goingDown + ? Math.Max(job.LastWrittenSp - step, targetCap) // 하강: rate는 양수, step만큼 감소 + : Math.Min(job.LastWrittenSp + step, targetCap); // 상승: 기존 + if (!goingDown) + nextSp = Math.Max(nextSp, job.LastWrittenSp); // 상승 단조(다운 점프 금지) + nextSp = Math.Clamp(nextSp, cfg.FeedSpMin, ClampMax(cfg.FeedSpMax)); + + if (double.IsNaN(nextSp) || double.IsInfinity(nextSp)) + { + _jobs.Update(job with { State = FeedRampState.Hold, Hold = "다음 SP 계산값 NaN/Inf", Warnings = adv.Warnings }); + return; + } + + // 진전 없음(ceiling 하락 등) → 쓰지 않고 대기 + if (nextSp <= job.LastWrittenSp + Eps) + { + _jobs.Update(job with + { + CurrentFeed = double.IsFinite(currentFeed) ? currentFeed : null, + Ceiling = double.IsFinite(ceiling) ? ceiling : null, RampRate = rampRate, + LastStepAt = now, Warnings = adv.Warnings, + Hold = double.IsFinite(ceiling) && ceiling < job.LastWrittenSp ? "ceiling 하락 — 현재 SP 유지" : job.Hold + }); + return; + } + + bool success; string? error = null; + if (dryRun) + { + success = true; + _logger.LogInformation("[FeedRamp][DRY] col={Col} tag={Tag} SP {From:F1}→{To:F1} (target {Tgt:F1}, rate {Rate:F1})", + cfg.Id, feedSpTag, job.LastWrittenSp, nextSp, targetCap, rampRate); + } + else + { + var ctrlId = await db.GetControllerIdForTagAsync(feedSpTag); // 태그→컨트롤러 DB 해석 + if (string.IsNullOrWhiteSpace(ctrlId)) + { + _jobs.Update(job with { State = FeedRampState.Hold, Hold = $"태그 {feedSpTag} 컨트롤러 미해석", RampRate = rampRate, Warnings = adv.Warnings }); + return; + } + (success, error) = await _writeClient.WriteTagAsync(ctrlId, feedSpTag, nextSp); + } + + var warnings = adv.Warnings; + if (success) + { + _jobs.Update(job with + { + LastWrittenSp = nextSp, LastStepAt = now, State = FeedRampState.Ramping, Hold = null, + CurrentFeed = double.IsFinite(currentFeed) ? currentFeed : null, + Ceiling = double.IsFinite(ceiling) ? ceiling : null, RampRate = rampRate, Warnings = warnings + }); + } + else + { + _jobs.Update(job with + { + LastStepAt = now, Hold = $"쓰기 실패: {error}", RampRate = rampRate, + Warnings = warnings + }); + } + + if (audit is not null) + await audit.LogAsync(new FfActionLogEntry(cfg.Id, "feed_ramp_write", + SpValue: nextSp, NodeId: feedSpTag, + Result: dryRun ? "dry-run" : (success ? "success" : $"error: {error}"), + OperatorName: job.Operator), ct); + } + + private static double ClampMax(double max) => (double.IsInfinity(max) || max >= double.MaxValue) ? 1e12 : max; + + // realtime_table에서 태그(예: 현재 FEED WSP) 값 1개 best-effort 조회 + private static async Task TryReadValueAsync(IExperionDbService db, string tag) + { + try + { + var rows = await db.GetRealtimeRecordsByTagNamesAsync(new[] { tag }); + var r = rows.FirstOrDefault(); + if (r?.LiveValue is not null && + double.TryParse(r.LiveValue, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var v)) + return v; + } + catch { /* best-effort */ } + return null; + } +} diff --git a/src/Infrastructure/Control/FeedRampJobStore.cs b/src/Infrastructure/Control/FeedRampJobStore.cs new file mode 100644 index 0000000..72e9095 --- /dev/null +++ b/src/Infrastructure/Control/FeedRampJobStore.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using Hc900Crawler.Core.Application.Feedforward; + +namespace Hc900Crawler.Infrastructure.Control; + +/// 작업 B: 컬럼별 활성 FEED 램프 작업 인메모리 저장소(싱글턴). +public sealed class FeedRampJobStore : IFeedRampJobStore +{ + private readonly ConcurrentDictionary _jobs = new(); + + public FeedRampJob Start(int columnId, double targetFeed, string op, bool dryRun) + { + var job = new FeedRampJob + { + ColumnId = columnId, + TargetFeed = targetFeed, + State = FeedRampState.Ramping, + StartedAt = DateTime.UtcNow, + Operator = op, + DryRun = dryRun + }; + _jobs[columnId] = job; + return job; + } + + public FeedRampJob? Get(int columnId) => _jobs.TryGetValue(columnId, out var j) ? j : null; + + public IReadOnlyCollection GetAll() => _jobs.Values.ToArray(); + + public void Update(FeedRampJob job) => _jobs[job.ColumnId] = job; + + public bool Cancel(int columnId) + { + if (!_jobs.TryGetValue(columnId, out var j)) return false; + _jobs[columnId] = j with { State = FeedRampState.Canceled }; + return true; + } +} diff --git a/src/Infrastructure/Control/FeedforwardConfigStore.cs b/src/Infrastructure/Control/FeedforwardConfigStore.cs index 0338a09..27ff880 100644 --- a/src/Infrastructure/Control/FeedforwardConfigStore.cs +++ b/src/Infrastructure/Control/FeedforwardConfigStore.cs @@ -34,8 +34,12 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore imbalance_trigger_frac, imbalance_trigger_sec, recovery_settle_sec, return_ramp_sec, feed_recovery_sp, delta_p_tag, delta_p_flood_limit, - advisory_only, - temp_high_limit + advisory_only, + temp_high_limit, temp_low_limit, + tc_return_reb_target, tc_return_reb_band, + tc_return_delta_ad_ref, tc_return_delta_ad_band, + controller_id, feed_sp_node_id, feed_sp_min, feed_sp_max, + tc_return_tc_target, tc_return_tc_band FROM ff_column_config """; await using var rd = await cmd.ExecuteReaderAsync(ct); @@ -86,6 +90,17 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28), DeltaPFloodLimit = rd.GetDouble(29), TempHighLimit = rd.GetDouble(31), + TempLowLimit = rd.GetDouble(32), + TcReturnRebTarget = rd.IsDBNull(33) ? double.NaN : rd.GetDouble(33), + TcReturnRebBand = rd.GetDouble(34), + TcReturnDeltaAdRef = rd.IsDBNull(35) ? double.NaN : rd.GetDouble(35), + TcReturnDeltaAdBand = rd.GetDouble(36), + ControllerId = rd.IsDBNull(37) ? "C1" : rd.GetString(37), + FeedSpNodeId = rd.IsDBNull(38) ? null : rd.GetString(38), + FeedSpMin = rd.GetDouble(39), + FeedSpMax = rd.GetDouble(40), + TcReturnTcTarget = rd.IsDBNull(41) ? double.NaN : rd.GetDouble(41), + TcReturnTcBand = rd.GetDouble(42), }; cols[cfg.Id] = (cfg, new List()); } @@ -163,15 +178,23 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore theta_auto_tune, bias_ma_window_sec, recovery_enabled, recovery_auto_arm, imbalance_trigger_frac, imbalance_trigger_sec, - recovery_settle_sec, return_ramp_sec, feed_recovery_sp, - delta_p_tag, delta_p_flood_limit, temp_high_limit) + recovery_settle_sec, return_ramp_sec, feed_recovery_sp, + delta_p_tag, delta_p_flood_limit, temp_high_limit, temp_low_limit, + tc_return_reb_target, tc_return_reb_band, + tc_return_delta_ad_ref, tc_return_delta_ad_band, + controller_id, feed_sp_node_id, feed_sp_min, feed_sp_max, + tc_return_tc_target, tc_return_tc_band) VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory, @tempTags,@sensTray,@dtdp,@pRef,@steamOp, @thetaAuto,@biasMaWin, @recEn,@recAutoArm, @imbFrac,@imbSec, @recSettle,@retRamp,@feedRecSp, - @deltaPTag,@deltaPFlood,@tempHigh) + @deltaPTag,@deltaPFlood,@tempHigh,@tempLow, + @tcRetRebTgt,@tcRetRebBand, + @tcRetDeltaAdRef,@tcRetDeltaAdBand, + @ctrlId,@feedSpNode,@feedSpMin,@feedSpMax, + @tcRetTcTgt,@tcRetTcBand) RETURNING id """; P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag); @@ -192,6 +215,16 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@tempHigh",cfg.TempHighLimit); + P(cmd,"@tempLow",cfg.TempLowLimit); + P(cmd,"@tcRetRebTgt",double.IsNaN(cfg.TcReturnRebTarget) ? DBNull.Value : (object)cfg.TcReturnRebTarget); + P(cmd,"@tcRetRebBand",cfg.TcReturnRebBand); + P(cmd,"@tcRetDeltaAdRef",double.IsNaN(cfg.TcReturnDeltaAdRef) ? DBNull.Value : (object)cfg.TcReturnDeltaAdRef); + P(cmd,"@tcRetDeltaAdBand",cfg.TcReturnDeltaAdBand); + P(cmd,"@ctrlId",string.IsNullOrWhiteSpace(cfg.ControllerId) ? "C1" : cfg.ControllerId); + P(cmd,"@feedSpNode",(object?)cfg.FeedSpNodeId ?? DBNull.Value); + P(cmd,"@feedSpMin",cfg.FeedSpMin); P(cmd,"@feedSpMax",cfg.FeedSpMax); + P(cmd,"@tcRetTcTgt",double.IsNaN(cfg.TcReturnTcTarget) ? DBNull.Value : (object)cfg.TcReturnTcTarget); + P(cmd,"@tcRetTcBand",cfg.TcReturnTcBand); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); } else @@ -209,7 +242,12 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm, imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec, recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp, - delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood, temp_high_limit=@tempHigh + delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood, + temp_high_limit=@tempHigh, temp_low_limit=@tempLow, + tc_return_reb_target=@tcRetRebTgt, tc_return_reb_band=@tcRetRebBand, + tc_return_delta_ad_ref=@tcRetDeltaAdRef, tc_return_delta_ad_band=@tcRetDeltaAdBand, + controller_id=@ctrlId, feed_sp_node_id=@feedSpNode, feed_sp_min=@feedSpMin, feed_sp_max=@feedSpMax, + tc_return_tc_target=@tcRetTcTgt, tc_return_tc_band=@tcRetTcBand WHERE id=@id """; P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); @@ -230,6 +268,16 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@tempHigh",cfg.TempHighLimit); + P(cmd,"@tempLow",cfg.TempLowLimit); + P(cmd,"@tcRetRebTgt",double.IsNaN(cfg.TcReturnRebTarget) ? DBNull.Value : (object)cfg.TcReturnRebTarget); + P(cmd,"@tcRetRebBand",cfg.TcReturnRebBand); + P(cmd,"@tcRetDeltaAdRef",double.IsNaN(cfg.TcReturnDeltaAdRef) ? DBNull.Value : (object)cfg.TcReturnDeltaAdRef); + P(cmd,"@tcRetDeltaAdBand",cfg.TcReturnDeltaAdBand); + P(cmd,"@ctrlId",string.IsNullOrWhiteSpace(cfg.ControllerId) ? "C1" : cfg.ControllerId); + P(cmd,"@feedSpNode",(object?)cfg.FeedSpNodeId ?? DBNull.Value); + P(cmd,"@feedSpMin",cfg.FeedSpMin); P(cmd,"@feedSpMax",cfg.FeedSpMax); + P(cmd,"@tcRetTcTgt",double.IsNaN(cfg.TcReturnTcTarget) ? DBNull.Value : (object)cfg.TcReturnTcTarget); + P(cmd,"@tcRetTcBand",cfg.TcReturnTcBand); await cmd.ExecuteNonQueryAsync(ct); } diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index d41cbd1..618fdda 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -43,6 +43,7 @@ public sealed class ColumnState public double ReturnTimerSec { get; set; } public bool OperatorArmed { get; set; } public bool OperatorCancel { get; set; } + public bool EnteredByTcLow { get; set; } // 전환류 진입이 T_C 하한(sigTLow) 때문이었나 — 원인별 복귀 게이트용 public Dictionary Streams { get; } = new(); public StreamState Stream(string key) @@ -429,7 +430,20 @@ public sealed class FeedforwardEngine ? pv.Temps.Where(t => t.Good && Num.IsFinite(t.Value)).Select(t => t.Value).DefaultIfEmpty(double.NaN).Max() : double.NaN; bool sigTHigh = Num.IsFinite(maxTemp) && Num.IsFinite(cfg.TempHighLimit) && maxTemp > cfg.TempHighLimit; - bool severe = sigVloss || sigFront || sigDp || tempSevere || sigTHigh; + // 작업플랜-민감단온도: T_C 하한 트리거 — 민감단(T_C)이 cfg.TempLowLimit 아래로 떨어지면 전환류 진입. + // TempLowLimit 기본값 -1e9 = 비활성. sigTHigh와 달리 T_C(민감트레이)만 감시. + double tcTemp = double.NaN; + if (pv.Temps is not null && cfg.SensitiveTrayTag is not null) + { + var key = cfg.SensitiveTrayTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase) + ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv"; + foreach (var t in pv.Temps) + if (t.Tag.Equals(key, StringComparison.OrdinalIgnoreCase) && t.Good && Num.IsFinite(t.Value)) + { tcTemp = t.Value; break; } + } + bool sigTLow = Num.IsFinite(tcTemp) && Num.IsFinite(cfg.TempLowLimit) && tcTemp < cfg.TempLowLimit; + // T_C 하한도 sigTHigh와 동일하게 단독 severe(운전원 명시 안전 한계) + bool severe = sigVloss || sigFront || sigDp || tempSevere || sigTHigh || sigTLow; // 비코러보 온도역전 — 센서 점검 메시지(severe 아님) string? sensorCheck = (sigInv && !corroborated) @@ -440,7 +454,8 @@ public sealed class FeedforwardEngine + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩 " : "") + (tempSevere ? "온도역전/붕괴 " : "") - + (sigTHigh ? $"온도HIGH({maxTemp:F1}>{cfg.TempHighLimit:F0}) " : ""); + + (sigTHigh ? $"온도HIGH({maxTemp:F1}>{cfg.TempHighLimit:F0}) " : "") + + (sigTLow ? $"온도LOW({tcTemp:F1}<{cfg.TempLowLimit:F0}) " : ""); switch (st.Mode) { @@ -451,6 +466,7 @@ public sealed class FeedforwardEngine { st.Mode = ColumnMode.Recovering; st.OperatorArmed = false; st.RecoverySettleTimerSec = 0; + st.EnteredByTcLow = sigTLow; // #3: 진입 원인 기록 — T_C로 진입 시 복귀도 T_C 회복 요구 return (ColumnMode.Recovering, $"전환류 진입: {SeverityText()}", OverrideRecovering(cfg, ref outs)); } if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec) @@ -460,7 +476,30 @@ public sealed class FeedforwardEngine case ColumnMode.Recovering: { var feedRec = OverrideRecovering(cfg, ref outs); - bool recovered = !severe && frac < cfg.ImbalanceTriggerFrac * 0.5; + // 물질수지 회복: 기존 조건 + bool mbRecovered = !severe && frac < cfg.ImbalanceTriggerFrac * 0.5; + // 작업플랜-민감단온도: 온도 기반 복귀 게이트 — reb-A in-band & ΔT(A-D) 안정 + bool tcRecovered = false; + // #2: reb-A=good temps의 max(보텀=최고온), T_D=min(탑=최저온). 위치([0]/[^1]) 가정 제거. + // 정상 프로파일이 깨지는 온도역전은 severe=true라 어차피 복귀 차단됨. + var goodTemps = pv.Temps?.Where(t => t.Good && Num.IsFinite(t.Value)) + .Select(t => t.Value).ToList(); + if (goodTemps is not null && goodTemps.Count >= 2) + { + double rebAv = goodTemps.Max(); + double topDv = goodTemps.Min(); + double deltaAd = rebAv - topDv; + bool rebInBand = Num.IsFinite(cfg.TcReturnRebTarget) + && Math.Abs(rebAv - cfg.TcReturnRebTarget) <= cfg.TcReturnRebBand; + bool deltaAdStable = Num.IsFinite(cfg.TcReturnDeltaAdRef) + && Math.Abs(deltaAd - cfg.TcReturnDeltaAdRef) <= cfg.TcReturnDeltaAdBand; + // #1: T_C in-band는 T_C 전용 target(TcReturnTcTarget) 사용. 미설정(NaN)이면 체크 생략. + bool tcInBand = !Num.IsFinite(cfg.TcReturnTcTarget) + || (Num.IsFinite(tcTemp) && Math.Abs(tcTemp - cfg.TcReturnTcTarget) <= cfg.TcReturnTcBand); + tcRecovered = rebInBand && deltaAdStable && tcInBand; + } + // #3: 진입 원인별 복귀 기준 — T_C 하한으로 진입했으면 T_C(온도) 회복 필수(물질수지 우회 불가). + bool recovered = st.EnteredByTcLow ? (tcRecovered && !severe) : (mbRecovered || tcRecovered); if (recovered) st.RecoverySettleTimerSec += ts; else st.RecoverySettleTimerSec = 0; if (st.RecoverySettleTimerSec >= cfg.RecoverySettleSec) { diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index afbc056..5a1ac0e 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -29,10 +29,12 @@ public sealed class FeedforwardSupervisor : BackgroundService IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard, ILogger logger, Microsoft.Extensions.Configuration.IConfiguration appConfig, - ISimOverrideStore sim, ICompositionStore composition) - { _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; _composition = composition; } + ISimOverrideStore sim, ICompositionStore composition, + IFfTrackingStore tracking) + { _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; _composition = composition; _tracking = tracking; } private readonly ICompositionStore _composition; + private readonly IFfTrackingStore _tracking; // WP5 3단계: LevelDriven 스트림의 분율을 수동입력(랩)값으로 치환(있으면). 없으면 config K. private ColumnConfig ApplyManualFractions(ColumnConfig cfg) @@ -78,15 +80,16 @@ public sealed class FeedforwardSupervisor : BackgroundService var snap = await BuildSnapshotAsync(db, cfg); var st = GetState(cfg.Id); var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow); - // Phase II: auto-write - // 안전가드: Sim Override 활성 시 입력이 가짜이므로 실제 쓰기 금지(advisory-only로 강등) - if (!cfg.AdvisoryOnly && writeClient is not null && auditService is not null && !_sim.Enabled) + // 측류 추종 쓰기 — 스트림별 토글이 ON인 경우에만(컬럼 AdvisoryOnly 무관, 토글이 곧 인가) + bool anyTracked = cfg.Streams.Any(s => _tracking.IsEnabled(cfg.Id, s.Key)); + // 안전가드: Sim Override 활성 시 입력이 가짜이므로 실제 쓰기 금지 + if (anyTracked && writeClient is not null && auditService is not null && !_sim.Enabled) { - await AutoWriteAsync(cfg, res, st, writeClient, auditService, ct); + await TrackingWriteAsync(cfg, res, writeClient, auditService, db, ct); res = res with { AutoWriteActive = true }; } - else if (!cfg.AdvisoryOnly && _sim.Enabled) - _logger.LogWarning("[FF] Sim Override 활성 — col {Id} auto-write 억제(가짜 입력)", cfg.Id); + else if (anyTracked && _sim.Enabled) + _logger.LogWarning("[FF] Sim Override 활성 — col {Id} 추종쓰기 억제(가짜 입력)", cfg.Id); _store.Set(res); } catch (Exception ex) @@ -103,30 +106,35 @@ public sealed class FeedforwardSupervisor : BackgroundService } } - // ── Phase II: auto-write ───────────────────────────────────────────── - private async Task AutoWriteAsync(ColumnConfig cfg, AdvisoryResult column, ColumnState st, - Hc900WriteService writeClient, IFeedforwardAuditService audit, CancellationToken ct) + // ── 측류 추종 쓰기 (스트림별 토글 ON인 경우만) ─────────────────────────── + private async Task TrackingWriteAsync(ColumnConfig cfg, AdvisoryResult column, + Hc900WriteService writeClient, IFeedforwardAuditService audit, IExperionDbService db, CancellationToken ct) { - if (column.Transient) - return; - foreach (var s in cfg.Streams) { - if (s.Role != StreamRole.Commanded) continue; - if (string.IsNullOrWhiteSpace(s.SpNodeId)) continue; // 쓰기 대상 미지정 + if (s.Role == StreamRole.Monitor) continue; // Monitor는 SP 없음 + if (!_tracking.IsEnabled(cfg.Id, s.Key)) continue; // 추종 OFF → 쓰기 안 함 + var spTag = FfSpTag.Resolve(s.FlowTag, s.SpNodeId); // flow 태그 → WSP(.SP) 자동 파생 + if (string.IsNullOrWhiteSpace(spTag)) continue; // flow 태그 없음 → 대상 불가 + var ctrlId = await db.GetControllerIdForTagAsync(spTag); // 태그→컨트롤러 DB 해석 + if (string.IsNullOrWhiteSpace(ctrlId)) + { + _logger.LogWarning("[FF] 추종 쓰기 보류 — 태그 {Tag} 컨트롤러 미해석", spTag); + continue; + } var adv = column.Streams.FirstOrDefault(a => a.Key == s.Key); if (adv is null) continue; - // 1) WriteGuard 검증 - var check = _writeGuard.Check(cfg, adv, s, column); + // 1) WriteGuard 검증 — 추종 토글이 곧 운전원 인가(manualOverride): AdvisoryOnly만 우회, 나머지 가드 유지 + var check = _writeGuard.Check(cfg, adv, s, column, manualOverride: true); if (!check.Allowed) { // 차단 로그 _lastWriteResults[(cfg.Id, s.Key)] = (adv.RecommendedSp, check.Reason, DateTime.UtcNow); await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write", StreamKey: s.Key, SpValue: adv.RecommendedSp, - NodeId: s.SpNodeId, Result: "blocked", + NodeId: spTag, Result: "blocked", WriteguardReason: check.Reason), ct); continue; } @@ -136,9 +144,9 @@ public sealed class FeedforwardSupervisor : BackgroundService var minInterval = TimeSpan.FromSeconds(Math.Max(cfg.ScanSec * 2, 2.0)); if (DateTime.UtcNow - lastWrite < minInterval) continue; - // 3) HC900 gRPC 쓰기 (SpNodeId를 HC900 태그명으로 사용) + // 3) HC900 gRPC 쓰기 (파생 WSP 태그, 해석된 컨트롤러로 라우팅) double spVal = adv.RecommendedSp!.Value; - var (success, error) = await writeClient.WriteTagAsync("HC1", s.SpNodeId, spVal); + var (success, error) = await writeClient.WriteTagAsync(ctrlId, spTag, spVal); // 4) 결과 저장 _lastWriteTimes[(cfg.Id, s.Key)] = DateTime.UtcNow; @@ -146,17 +154,17 @@ public sealed class FeedforwardSupervisor : BackgroundService { _lastWriteResults[(cfg.Id, s.Key)] = (spVal, null, DateTime.UtcNow); _logger.LogInformation("[FF] SP 쓰기 성공 col={Col} stream={Key} node={Node} val={Val:F2}", - cfg.Id, s.Key, s.SpNodeId, spVal); + cfg.Id, s.Key, spTag, spVal); } else { _lastWriteResults[(cfg.Id, s.Key)] = (spVal, error, DateTime.UtcNow); _logger.LogWarning("[FF] SP 쓰기 실패 col={Col} stream={Key} node={Node} err={Err}", - cfg.Id, s.Key, s.SpNodeId, error); + cfg.Id, s.Key, spTag, error); } await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write", - StreamKey: s.Key, SpValue: spVal, NodeId: s.SpNodeId, + StreamKey: s.Key, SpValue: spVal, NodeId: spTag, Result: success ? "success" : $"error: {error}"), ct); } } diff --git a/src/Infrastructure/Control/FeedforwardWriteGuard.cs b/src/Infrastructure/Control/FeedforwardWriteGuard.cs index f5c3248..d8c8633 100644 --- a/src/Infrastructure/Control/FeedforwardWriteGuard.cs +++ b/src/Infrastructure/Control/FeedforwardWriteGuard.cs @@ -4,12 +4,17 @@ namespace Hc900Crawler.Infrastructure.Control; public sealed class FeedforwardWriteGuard : IFeedforwardWriteGuard { - public WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column) + public WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column, bool manualOverride = false) { - if (adv.Role != StreamRole.Commanded) - return new WriteCheckResult(false, "Commanded 스트림만 SP 쓰기 대상"); + // Monitor는 SP 없음. Commanded는 자동·수동 모두 / LevelDriven(D·B 등 피드결정형)은 운전원 수동 추종일 때만. + if (adv.Role == StreamRole.Monitor) + return new WriteCheckResult(false, "Monitor 스트림은 SP 없음"); + if (adv.Role == StreamRole.LevelDriven && !manualOverride) + return new WriteCheckResult(false, "LevelDriven은 자동쓰기 대상 아님(수동 추종만)"); - if (cfg.AdvisoryOnly) + // D-1 정정: manualOverride(운전원 버튼)일 때만 AdvisoryOnly를 건너뛴다. + // 아래 Valid/Grade/Transient/범위/NaN 가드는 manualOverride 여부와 무관하게 항상 적용. + if (cfg.AdvisoryOnly && !manualOverride) return new WriteCheckResult(false, "컬럼이 AdvisoryOnly 모드"); if (adv.RecommendedSp is not double sp) diff --git a/src/Infrastructure/Control/FfTrackingStore.cs b/src/Infrastructure/Control/FfTrackingStore.cs new file mode 100644 index 0000000..fed3970 --- /dev/null +++ b/src/Infrastructure/Control/FfTrackingStore.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; +using Hc900Crawler.Core.Application.Feedforward; + +namespace Hc900Crawler.Infrastructure.Control; + +/// 측류 스트림별 추종 상태 인메모리 저장소(싱글턴). +public sealed class FfTrackingStore : IFfTrackingStore +{ + private readonly ConcurrentDictionary<(int, string), FfTrackingState> _states = new(); + + public void Set(FfTrackingState state) => _states[(state.ColumnId, state.StreamKey)] = state; + + public bool IsEnabled(int columnId, string streamKey) + => _states.TryGetValue((columnId, streamKey), out var s) && s.Enabled; + + public FfTrackingState? Get(int columnId, string streamKey) + => _states.TryGetValue((columnId, streamKey), out var s) ? s : null; + + public IReadOnlyCollection GetAll() => _states.Values.ToArray(); +} diff --git a/src/Infrastructure/Database/Hc900DbContext.cs b/src/Infrastructure/Database/Hc900DbContext.cs index c88f15a..f11a1fb 100644 --- a/src/Infrastructure/Database/Hc900DbContext.cs +++ b/src/Infrastructure/Database/Hc900DbContext.cs @@ -1179,6 +1179,18 @@ public class Hc900DbService : IExperionDbService ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_high_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_low_limit DOUBLE PRECISION NOT NULL DEFAULT -1e9; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_reb_target DOUBLE PRECISION; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_reb_band DOUBLE PRECISION NOT NULL DEFAULT 0.5; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_delta_ad_ref DOUBLE PRECISION; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_delta_ad_band DOUBLE PRECISION NOT NULL DEFAULT 0.4; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_tc_target DOUBLE PRECISION; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS tc_return_tc_band DOUBLE PRECISION NOT NULL DEFAULT 1.0; + -- 작업: 측류 SP 쓰기 컨트롤러 라우팅 + FEED 램프 실행 대상 + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'C1'; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_sp_node_id TEXT; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_sp_min DOUBLE PRECISION NOT NULL DEFAULT 0; + ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_sp_max DOUBLE PRECISION NOT NULL DEFAULT 1e9; -- migration: missing columns from the original CREATE TABLE (schema was json-based) ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_tag TEXT NOT NULL DEFAULT ''; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS pressure_tag TEXT; @@ -1767,6 +1779,26 @@ public class Hc900DbService : IExperionDbService throw new NotImplementedException("OPC UA method not applicable in HC900Crawler"); } + public async Task GetControllerIdForTagAsync(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) return null; + // 베이스 태그(점 앞)로 매칭 — 같은 루프의 어떤 파라미터든 같은 컨트롤러 + var baseLower = tagName.Split('.')[0].ToLowerInvariant(); + var prefix = baseLower + "."; + try + { + return await _ctx.RealtimePoints + .Where(x => x.TagName.ToLower() == baseLower || x.TagName.ToLower().StartsWith(prefix)) + .Select(x => x.ControllerId) + .FirstOrDefaultAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Realtime] 태그 {Tag} 컨트롤러 해석 실패", tagName); + return null; + } + } + public async Task> GetRealtimeRecordsByTagNamesAsync(IEnumerable tagNames) { try @@ -1774,8 +1806,9 @@ public class Hc900DbService : IExperionDbService var tags = tagNames.ToList(); if (tags.Count == 0) return Enumerable.Empty(); + var lowerTags = tags.Select(t => t.ToLowerInvariant()).ToList(); var records = await _ctx.RealtimePoints - .Where(x => tags.Contains(x.TagName)) + .Where(x => lowerTags.Contains(x.TagName.ToLower())) .ToListAsync(); _logger.LogDebug("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count); @@ -2243,6 +2276,42 @@ public class Hc900DbService : IExperionDbService return await _ctx.SaveChangesAsync(); } + private Dictionary? _tagNameMap; + + private async Task> GetTagNameMapAsync() + { + if (_tagNameMap != null) return _tagNameMap; + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var entries = await _ctx.Hc900MapEntries + .Where(m => m.IsActive) + .Select(m => m.TagName) + .Distinct() + .ToListAsync(); + + foreach (var t in entries) + { + map[t] = t; + var baseName = t.EndsWith(".PV", StringComparison.OrdinalIgnoreCase) + ? t[..^3] : t; + if (!map.ContainsKey(baseName)) + map[baseName] = t; + } + + _tagNameMap = map; + return map; + } + + private static string MapTagName(string tagName, Dictionary map) + { + if (map.TryGetValue(tagName, out var canonical)) + return canonical; + if (tagName.EndsWith(".pv", StringComparison.OrdinalIgnoreCase) + && map.TryGetValue(tagName[..^3], out canonical)) + return canonical; + return tagName; + } + public async Task> QueryEventHistoryAsync( string? tagName, string? area, string? subArea, string? eventType, DateTime from, DateTime to, int limit = 500) @@ -2267,10 +2336,12 @@ public class Hc900DbService : IExperionDbService .Take(limit) .ToListAsync(); + var map = await GetTagNameMapAsync(); + return records.Select(r => new EventHistoryRow { Id = r.Id, - TagName = r.TagName, + TagName = MapTagName(r.TagName, map), NodeId = r.NodeId, PrevValue = r.PrevValue, CurrValue = r.CurrValue, diff --git a/src/Infrastructure/Hc900/Hc900RealtimeService.cs b/src/Infrastructure/Hc900/Hc900RealtimeService.cs index 1f96640..055e5ec 100644 --- a/src/Infrastructure/Hc900/Hc900RealtimeService.cs +++ b/src/Infrastructure/Hc900/Hc900RealtimeService.cs @@ -62,8 +62,8 @@ public class Hc900RealtimeService : BackgroundService, IHc900RealtimeService var mapping = await LoadMappingAsync(controllerId, ct); _logger.LogInformation("[HC900Realtime] [{Id}] 태그 매핑 {Count}개 로드", controllerId, mapping.Count); - var stateLabels = await LoadStateLabelsAsync(controllerId, ct); - _logger.LogInformation("[HC900Realtime] [{Id}] 상태 레이블 {Count}개 로드", controllerId, stateLabels.Count); + _stateLabels = await LoadStateLabelsAsync(controllerId, ct); + _logger.LogInformation("[HC900Realtime] [{Id}] 상태 레이블 {Count}개 로드", controllerId, _stateLabels.Count); while (!ct.IsCancellationRequested) {
KeyFlow 태그역할레벨태그Kθ_upθ_dnτSP_minSP_maxRate_upRate_dn환류전환류R복귀SPSP NodeId신뢰SP_minSP_maxRate_upRate_dn환류전환류R복귀SPSP override신뢰