Compare commits
7 Commits
main
...
7b21c35af6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b21c35af6 | ||
|
|
447ccfa2b8 | ||
|
|
23a9202b91 | ||
|
|
3a7c9d60c3 | ||
|
|
b45f0e2481 | ||
|
|
4306f76ddb | ||
|
|
1bc46b1eb0 |
263
docs/PITOPS-브레인스토밍-웹페이지복사.md
Normal file
263
docs/PITOPS-브레인스토밍-웹페이지복사.md
Normal file
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
34
docs/작업지시서-STEAM-SP-FF통합.md
Normal file
34
docs/작업지시서-STEAM-SP-FF통합.md
Normal file
@@ -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 행 → ④로깅. ①②는 웹패키징과 독립 병행 가능.
|
||||
169
docs/작업지시서-operator-assist-웹패키징.md
Normal file
169
docs/작업지시서-operator-assist-웹패키징.md
Normal file
@@ -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] "더 좋은 방법 제안"과 "현재 코드가 틀렸다" 혼동하지 않음
|
||||
|
||||
317
docs/작업지시서-측류SP쓰기-피드램프실행.md
Normal file
317
docs/작업지시서-측류SP쓰기-피드램프실행.md
Normal file
@@ -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` — `<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>`, 대시보드 컨테이너 `#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` 칸 옆 또는 신뢰칸에 `<button class="btn sm" onclick="ffWriteSp(${c.columnId},'${s.key}',${s.recommendedSp})">SP 반영</button>`.
|
||||
- 핸들러 `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<int, FeedRampJob>`; `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<FeedRampExecutorService>()` + `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` |
|
||||
236
docs/작업지시서-학습형제어-다음단계.md
Normal file
236
docs/작업지시서-학습형제어-다음단계.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 작업지시서 — 학습형 제어 다음 단계 (6-1차 이후)
|
||||
|
||||
> 작성 2026-06-05. 전체 설계·진행로그는 `docs/학습형제어-오퍼레이터모방-플랜.md`(특히 §0 오리엔테이션, §15 데이터, §16 분석).
|
||||
> 메모리: `~/.claude/projects/-home-windpacer-projects-hc900-ax/memory/learned-control-operator-imitation.md` (Claude 메모리 — **세션 시작 시 MEMORY.md 인덱스로 자동 로드**. 리포지토리 밖이라 cwd에서 `ls`로는 안 보임). 이 문서는 **남은 4개 작업의 실행 지시서**.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 진단 결과 (diagnosis-checklist.md 규칙 적용, 2026-06-05)
|
||||
|
||||
**진단 대상**: `docs/작업지시서-학습형제어-다음단계.md`
|
||||
**방법**: `diagnosis-checklist.md` 8단계 적용 (STEP 1 맥락 → 2 구조탐색 → 3 코드읽기 → 4 호출계층 → 5 체크리스트 → 6 교차검증 → 7 심각도 → 8 보고서)
|
||||
|
||||
### 항목 1. ~~참조 메모리 파일 미존재~~ → 오탐 (제거)
|
||||
|
||||
**오탐 사유**: `learned-control-operator-imitation.md`는 `/home/windpacer/.claude/projects/-home-windpacer-projects-hc900-ax/memory/`에 정상 존재(5144B). Claude 메모리 디렉토리(리포지토리 밖)에 있어 `cwd` 기준 `ls`로는 발견되지 않았음.
|
||||
**STEP 6 Q1 교차검증 탈락**: 파일 실재 → 보고서에서 제거.
|
||||
**보강 제안**: line 4에 전체 경로를 명시하면 다음 세션 컨텍스트 복원에 유리함.
|
||||
|
||||
### 항목 2. DB 스키마 구분 불명시 (MED)
|
||||
|
||||
**문제**: 분석용 DB(`field_hist`)는 `public` schema만 있고 `hc900` schema는 `iiot_platform` DB 소속. `hc900.ff_column_config`는 `iiot_platform` DB에만 존재(C-6111 단독). line 18이 `hc900.ff_column_config`를 마치 `field_hist`에서 조회 가능한 자원인 것처럼 서술하고 있음.
|
||||
**근거**: `field_hist` DB 확인 — schemas: public, information_schema, pg_catalog, pg_toast (hc900 無). `iiot_platform` DB 확인 — `hc900.ff_column_config` → `(1, C-6111, t)` 단독.
|
||||
**영향**: 작업1에서 "ff_column_config엔 C-6111만 있을 가능성"이라는 가정은 확인됨(사실). 해결책(ptlist 기반 ROLES 유도)이 이미 명시되어 있으므로 실행 차질은 없으나, 향후 ff_config 테이블 직접 참조 시 혼란 발생.
|
||||
**수정**: line 18에 "`hc900.ff_column_config` → `field_hist`의 ptlist로 간접 검증(ff_config는 `iiot_platform` DB에 존재)" 추가 명시.
|
||||
|
||||
### 항목 3. `run_column.py` 경로 미명시 (LOW)
|
||||
|
||||
**문제**: 작업1 step 3가 `run_column.py` 래퍼를 만들라고 지시하나, 위치(scripts/analysis/? scripts/?) 불명시.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:41`
|
||||
**영향**: 구현 시 경로 결정이 미뤄짐. 다른 분석 스크립트와의 정합성(import 경로, BASE 상수)이 깨질 위험.
|
||||
**수정**: `scripts/analysis/run_column.py`로 위치 명시 권장.
|
||||
|
||||
### 항목 4. `predict(live_tags)` 입력 포맷 미명시 (LOW)
|
||||
|
||||
**문제**: 작업3 step 1의 `predict(live_tags)`가 받는 `live_tags`의 데이터 구조(dict? 키 형식? gRPC 태그명?)가 정의되지 않음.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:79`
|
||||
**영향**: 작업3 착수 시 인터페이스 결정 필요. C# 포팅(작업4)의 `SteamAdvisor`와 인터페이스 불일치 가능.
|
||||
**수정**: 입력 구조 예시 추가: `{"feed": 534.2, "product": 318.5, "T_C": 84.7}` 또는 gRPC `ReadTagsResponse` 참조.
|
||||
|
||||
### 항목 5. 작업4 모델 포맷 복수 경로 리스크 (LOW)
|
||||
|
||||
**문제**: 작업4 step 1이 "JSON 계수/룩업 또는 ONNX"로 포맷을 열어둠. 두 갈래 중 선택 지연 시 C# 포팅 시작 전 방향성 분산 위험.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:101`
|
||||
**영향**: Python export ↔ C# import 파이프라인이 병렬로 갈 수 있음.
|
||||
**수정**: 문서에서 1안(GBM 계수→C# 직접 계산)을 권장하고 ONNX는 모델 복잡도 증가 시 fallback으로 명시.
|
||||
|
||||
### 교차 검증 통과 내역
|
||||
|
||||
| 항목 | Q1 (이미수정?) | Q2 (타레이어?) | Q3 (의도적?) | Q4 (재현?) |
|
||||
|------|:---:|:---:|:---:|:---:|
|
||||
| #1 ~~메모리 파일~~ | 오탐 | 오탐 | 오탐 | 오탐 |
|
||||
| #2 DB 스키마 | No | 부분완화 | No | Yes |
|
||||
| #3 run_column | No | No | No | 부분 |
|
||||
| #4 predict 포맷 | No | No | No | 부분 |
|
||||
| #5 모델 포맷 | No | No | No | 부분 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 현재까지 (필독 컨텍스트)
|
||||
|
||||
✔ **6-1차 (C-6111, 오프라인 분석 완주):**
|
||||
- ① 생산제어: GBM R²0.993, shadow 94% 모방, 롤링 MAE 1.17%, startup 컷인 3건 clean.
|
||||
- ② START-UP: ★컷인 트리거 reb-A 84.6±0.5℃ & ΔT(A-D) 1.9±0.4℃.
|
||||
|
||||
✔ **6-2차 (C-6211, 2026-06-05 완료):**
|
||||
- GBM R² **0.997**(전 컬럼 최고), PROD 98.8%, steam/feed=0.623.
|
||||
- shadow 79.7% (OOD 53%), 롤링 OP MAE **1.07%**(최고).
|
||||
- startup 5건, shutdown 5건.
|
||||
|
||||
✔ **8차 (C-8111, 2026-06-05 완료):**
|
||||
- GBM R² **0.630**(PROD 59.4%), shadow 66.9%, valve stiction 2.8%.
|
||||
- startup 1건(reb-A 96.2℃), shutdown 1건(ΔT 17℃).
|
||||
|
||||
✔ **9차 (C-9111, 2026-06-05 완료):**
|
||||
- GBM R² 0.886, steam/feed=0.929. shadow 63.5%.
|
||||
- startup 25건 과다탐지, shutdown 22건(3개 하위유형).
|
||||
|
||||
✔ **10차 (C-10111, 2026-06-05 완료):**
|
||||
- PROD **1.7%**, 분석 신뢰도 낮음. rolling 5월 이전 조기종료.
|
||||
|
||||
✔ **SHUTDOWN (2026-06-05, 작업2 완료):**
|
||||
- `detect_cutoffs()` — product>100→<50 + steam 동반 하강.
|
||||
- 6-1(4건 일관): ★셧다운 트리거 reb-A=84.7℃, steam 1분→진공 4분→냉각 140분+.
|
||||
- 9차(22건): A-type(reb~82℃/정지, 14건), B-type(reb~99℃/changeover, 4건), C-type(reb~74℃/저부하, 3건).
|
||||
|
||||
✔ **Operator Assist (2026-06-05, 작업3 완료):**
|
||||
- `OperatorAssist` 클래스: predict(live_tags)→advisory + OOD 게이트 + mode 분류.
|
||||
- `c6111_operator_assist.py` — `--data`/`--prefix` CLI로 모든 컬럼 호환.
|
||||
- Advisory 성능: 6-1 \|Δ|≤2% **92.2%** ✅, 6-2 **93.1%** ✅(목표 90%+ 달성).
|
||||
- 8(84.3%)·9(80.8%)·10(61.9%)는 PROD 데이터 부족으로 미달.
|
||||
|
||||
**환경/자산:**
|
||||
- DB: `field_hist` (별도 DB, PG16 컨테이너 `iiot-timescaledb`, localhost:5432, postgres/postgres). shinam 실데이터 2026-02~06 ~30초. WIDE 포맷(§15.2~15.3 디코드).
|
||||
- Python: `mcp-server/.venv/bin/python` (psycopg3=`psycopg`, pandas, sklearn1.8, matplotlib. **psycopg2·pyarrow 없음** → pickle 사용).
|
||||
- 코드: `scripts/analysis/c6111_*.py` — **재사용 추출기 `tag_frame(conn, role_map)`** (c6111_extract.py), `SteamPredictor`(c6111_shadow.py).
|
||||
- C-6111 토폴로지: 기존 `hc900.ff_column_config`(column C-6111, advisory_only=t)/`ff_stream_config`에서 권위 정의(§16.1). ⚠️ 이 테이블들은 **`iiot_platform` DB의 hc900 스키마**에 있음(분석 DB `field_hist`엔 없음 — field_hist는 public 스키마만). 형제 컬럼(6-2·8·9·10)은 ff_config 행이 없을 수 있어 ptlist 네이밍으로 ROLES 유도(작업1).
|
||||
|
||||
**핵심 gotcha:**
|
||||
- 컬럼 디코드: `ptname→ptlist.pid→mapping(tid,oit)→cont{tbl}.col{oit:02d}`, 시간축 `dtat`.
|
||||
- **운전점 집계가 필수**: 정상상태 98%라 점단위 회귀는 음의 R² → 6h 중앙값으로 집계해야 맞음(§16.6).
|
||||
- **OOD 게이트 필수**: 새 로드레짐은 외삽 실패 → 롤링 재학습 + OOD→오퍼레이터 폴백(§16.7~8).
|
||||
- pandas `df.product`는 메서드와 충돌 → `df["product"]`. col은 0패딩(`col03`). `pd.read_sql`는 psycopg3에 경고만(동작OK).
|
||||
|
||||
---
|
||||
|
||||
## 작업 1 — (4) 6-2·8·9·10 형제 컬럼 확장 [우선순위 1]
|
||||
|
||||
**목적**: 동일 코드가 형제 측류 솔벤트 컬럼(6-2, 8, 9, 10차)에 그대로 도는지 검증 + startup 샘플 보강.
|
||||
|
||||
**선행 확인**:
|
||||
1. 형제 컬럼 토폴로지 출처: `ff_column_config`엔 **C-6111(6-1)만** 있을 가능성 → 6-2/8/9/10은 **태그 네이밍 규칙으로 ROLES 유도**.
|
||||
- 6-1 규칙(끝자리 역할): feed=FICQ-**6101**, reflux=FICQ-6113, light(D)=6114, heavy(B)=6116, product(P)=6118, steam밸브=TICA-**6111A**.OP, steam유량=FIQ-6115, 민감단=TI-6111**C**, 진공=PICA-6111, DP=PI-6111B, 온도프로파일 TI-6111A/B/C/D.
|
||||
- 일반화: 차수·train 접두 `{P}1` (6-1=61, 6-2=62, 8=81, 9=91, 10=101). 즉 6-2=FICQ-6201/6213/6214/6216/6218, TICA-6211A 등.
|
||||
2. **반드시 ptlist로 검증**: 각 형제의 실제 태그가 같은 끝자리 규칙인지 쿼리로 확인(8/9/10은 다를 수 있음).
|
||||
|
||||
**단계**:
|
||||
1. `c6111_extract.py`의 `ROLES`를 **함수 `roles_for(prefix)`로 파라미터화**(예: prefix="62"→ FICQ-6201…). asset도 해당 차수(`/ASSETS/P6`는 6-1·6-2 공통, 8/9/10은 `/ASSETS/P8/9/10`).
|
||||
2. ptlist로 각 컬럼 ROLES 자동검증(미해결 태그 경고). 끝자리 규칙이 다르면 컬럼별 매핑표 작성.
|
||||
3. 추출→운전모드 분류(c6111_extract) → 생산맵(c6111_prodmap) → shadow/롤링(c6111_shadow/rolling) → startup(c6111_startup)을 **컬럼 인자로** 일괄 실행하는 **`scripts/analysis/run_column.py`** 래퍼 작성(기존 분석 스크립트와 같은 디렉토리·`BASE` 상수 정합 유지).
|
||||
4. 컬럼별 산출: steam/feed비, 맵 R², shadow 일치율, startup 컷인 트리거.
|
||||
|
||||
**산출물**: 형제별 결과표(steam/feed, R², 컷인트리거) + 통합 startup 트리거(샘플↑로 reb-A/ΔT 변동성 재추정).
|
||||
|
||||
**검증기준**: 각 형제 생산맵 R²>0.95(운전점), shadow in-envelope 일치율>85%. startup 컷인 트리거가 6-1과 정합(같은 솔벤트 유형이면 유사 예상).
|
||||
|
||||
**주의**: 8/9/10은 컬럼 크기·운전조건 다를 수 있음 → steam/feed비·트리거가 6-1과 다르면 그게 정상(컬럼별 학습). 죽은 플랜트 3·4차 제외. 5차는 유형 미확인(§15.9).
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- 전 컬럼 추출·prodmap·shadow·rolling·startup·shutdown 완료 (run_column.py로 일괄).
|
||||
- R²>0.95 달성: **6-1(0.993) ✓, 6-2(0.997) ✓**. 8(0.630)·9(0.886)는 미달(운전점 부족). 10(0.818) PROD 1.7%라 신뢰어려움.
|
||||
- shadow in-envelope>85% 달성: **6-1(94%) ✓**. 6-2(79.7%, OOD 53%)·8(66.9%)·9(63.5%) 미달.
|
||||
- 컷인 트리거: 6-1(reb-A 84.6±0.5℃)·6-2(82.3±2.6℃) 유사. 8(96.2℃)·9(83.0±7.7℃)·10(93.8±9.3℃)는 분산 큼.
|
||||
|
||||
---
|
||||
|
||||
## 작업 2 — ③ SHUTDOWN 절차 [우선순위 2]
|
||||
|
||||
**목적**: 안전 정지 절차 학습(startup의 역순). few-shot.
|
||||
|
||||
**선행**: `c6111_startup.py`를 템플릿으로. SHUTDOWN = **제품 컷오프(product >100→0 하강엣지)** 기준.
|
||||
|
||||
**단계**:
|
||||
1. `detect_cutins`를 미러링한 `detect_cutoffs` 작성: product 하강엣지(>100→<50)이고 직후 steam도 내려감(shutdown 진입) 탐지.
|
||||
2. 컷오프 정렬 후 역시퀀스 추출: 생산 → **피드 감소** → **제품 컷오프** → 전환류 복귀? → **스팀 차단** → **진공 해제** → 냉각.
|
||||
3. 단계 타이밍 + 트리거: "언제 피드 줄이기 시작? 제품 컷오프 시 컬럼상태? 스팀 차단 순서?" 추출.
|
||||
4. startup 에피소드 목록(02-15, 04-30, 05-02, 05-13 등)에 대응하는 shutdown이 직전에 있음(같은 에피소드의 앞부분) → 재활용.
|
||||
|
||||
**산출물**: SHUTDOWN 레시피(단계 시퀀스+타이밍+트리거) + 플롯(컷오프 정렬 중첩).
|
||||
|
||||
**검증기준**: 깨끗한 shutdown ≥3건에서 시퀀스 일관. 안전 관점(급격 차단 없이 순차 감소) 확인.
|
||||
|
||||
**주의**: shutdown은 안전 최우선 — 급격 진공해제/스팀차단 순서가 장비보호에 중요. 데이터의 순서·rate를 그대로 보존.
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_shutdown.py` 생성 — `--data`/`--prefix` CLI로 형제 컬럼 호환.
|
||||
- 전 컬럼 실행 완료. 6-1(4건)·6-2(5건)는 일관 시퀀스 확인, 9차(22건)는 3개 하위유형 식별.
|
||||
- 셧다운 레시피 대표: **제품컷오프(trigger=reb-A 84.7℃) → steam 1분 차단 → 진공 4분 해제 → 냉각 140분+** (6-1 기준).
|
||||
- 8·10차는 샘플 부족(1건·7건 산발)으로 일반화 어려움.
|
||||
|
||||
---
|
||||
|
||||
## 작업 3 — (2) operator-assist 패키징 [우선순위 3, 현장투입 1단계]
|
||||
|
||||
**목적**: ①의 예측기를 **"권장 OP + 신뢰도" 자문 출력**으로 패키징(write 안 함). 현장 신뢰구축 단계.
|
||||
|
||||
**선행**: `SteamPredictor`(c6111_shadow.py) + OOD 게이트 + 롤링 재학습 로직.
|
||||
|
||||
**단계**:
|
||||
1. **예측 서비스화**(Python 먼저): `predict(live_tags) → {rec_OP, confidence(in/OOD), rec_steam_flow}`.
|
||||
- **입력 포맷**: `live_tags = {"feed": 534.2, "product": 318.5, "T_C": 84.7}` (FEATURES 키, 평활 전 원값). C# 포팅(작업4) `SteamAdvisor`도 **동일 키 계약** 유지.
|
||||
- 입력 평활(인과 trailing), 운전점맵 예측→밸브역특성→OP, envelope 체크.
|
||||
- 롤링 재학습 스케줄(일/주 단위, expanding 또는 trailing window).
|
||||
2. **모드 인지**: 현재 운전모드(PROD/LINEOUT/STARTUP…) 분류(c6111_extract.classify_phases) → PROD에서만 ① 맵 조언, STARTUP이면 ② 레시피(컷인 게이트) 조언.
|
||||
3. **출력 UI**: 기존 `...AdvisorService`/FF 자산(FeedforwardSupervisor, FfTrackingStore) 패턴으로 화면에 "권장 OP=X% (신뢰: 구간내/범위밖)" 표시 + 오퍼레이터 실조작 병행 로깅(비교).
|
||||
4. shadow 로깅: 권장 vs 실제 OP 차이 누적 → 신뢰 리포트.
|
||||
|
||||
**산출물**: 자문 API/서비스 + 화면 + 권장-vs-실제 로그/리포트.
|
||||
|
||||
**검증기준**: 신뢰구간에서 권장 OP가 오퍼레이터와 ±2% 이내 90%+(§16.7 재현). OOD 시 "범위밖→수동" 정확 표시.
|
||||
|
||||
**주의**: **절대 write 금지**(advisory_only). OOD·비-PROD에선 조언 보류(폴백). hunting 공포 고려 — 조언도 gentle(작은 변화).
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_operator_assist.py` — `OperatorAssist` 클래스: predict(live_tags) + OOD 게이트(percentile envelope + IForest) + mode 분류 + shadow 리플레이 리포트.
|
||||
- `run_column.py`에 포함됨. 전 컬럼 shadow advisory 리포트 완료.
|
||||
- **검증기준(±2% 90%+) 달성: 6-1(92.2%) ✅, 6-2(93.1%) ✅.** 8(84.3%)·9(80.8%)·10(61.9%) 미달.
|
||||
|
||||
---
|
||||
|
||||
## 작업 4 — (5) live C# shadow 포팅 [우선순위 4, 실플랜트 연결]
|
||||
|
||||
**목적**: 검증된 예측기를 C# 크롤러에 포팅, **플랜트6 live(gRPC)** 에 shadow 연결.
|
||||
|
||||
**선행**: 작업3 로직 확정. 플랜트6 HC900 통신 살아있음(live값 가공이라도 경로 테스트 가능, §0.2). `ff_column_config` C-6111 advisory_only=t 이미 설정.
|
||||
|
||||
**단계**:
|
||||
1. **모델 산출물 export** — **(1안 권장) JSON 계수→C# 직접 계산**: 선형/룩업 계수(`steam≈0.73·feed+보정`), 밸브역특성 3차계수, envelope min/max를 JSON으로 export. 단순·투명·의존성 0. **(2안 fallback) ONNX**: 선형근사가 부족하고 GBM 정확도가 꼭 필요할 때만. **우선 1안으로 시작.**
|
||||
2. **C# Infrastructure/Control에 예측기**: 기존 `FeedforwardSupervisor`/`FeedRampAdvisor` 옆에 `SteamAdvisor` 추가. gRPC 실시간 태그(피드 FICQ-6101, 제품 6118, T_C TI-6111C, reb-A, 진공 등) 읽어 권장 OP 산출.
|
||||
3. **운전모드 분류 + OOD** C#에 포팅(임계 §16.3-2, envelope §16.7).
|
||||
4. shadow 로깅: `FfTrackingStore`에 권장 vs 실제 OP 기록. 화면 노출(advisory).
|
||||
5. 롤링 재학습: 주기적 Python 재학습→계수 갱신 파이프(또는 C#서 온라인 회귀).
|
||||
|
||||
**산출물**: C# `SteamAdvisor` 서비스(advisory) + live shadow 로그 + UI.
|
||||
|
||||
**검증기준**: live 태그로 권장 OP 산출·표시 동작. (live 데이터가 가공이라 정확도보다 **경로·안전(OOD폴백)·로깅** 검증 우선.)
|
||||
|
||||
**주의**: live값이 현재 시뮬/가공 → 정확도 평가는 field_hist 백테스트로, live는 통합·안전 검증용. write 금지. 실제 실데이터 확보 시 재검증.
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_export_model.py` — 선형근사(1안) JSON export: linear 회귀계수·밸브역특성 3차계수·envelope min/max.
|
||||
- C6-1 선형 R²=0.986(GBM 0.995 대비 99% 설명). C6-2 선형 R²=0.996(GBM 0.998).
|
||||
- `SteamAdvisor.cs` (`src/Infrastructure/Control/`): `Predict(feed, product, tC)→SteamAdvisoryResult`, `ClassifyMode()`, `InEnvelope()`.
|
||||
- `SteamAdvisorController.cs` (`src/Hc900Crawler/Controllers/`): `GET /api/steam/health`, `GET/POST /api/steam/predict`.
|
||||
- `Program.cs` DI 등록 완료(`AddSingleton<SteamAdvisor>()`). `appsettings.json`에 `SteamAdvisor:ModelPath` 설정.
|
||||
|
||||
**⚠️ 선형근사 한계**: P8(0.659)·P9(0.161)·P10(0.202)는 선형 R² 낮음 → **2안(ONNX 또는 Python shadow 호출)** 으로 전환 검토 필요.
|
||||
|
||||
---
|
||||
|
||||
## 권장 순서
|
||||
**작업1(형제확장) → 작업2(shutdown) → 작업3(operator-assist) → 작업4(live포팅)**.
|
||||
|
||||
**✅ 1·2·3 완료 (2026-06-05).** ~~작업4(live포팅)만 남음.~~
|
||||
|
||||
## 진행 현황
|
||||
|
||||
| 작업 | 상태 | 산출물 | 검증 |
|
||||
|:----|:----:|:-------|:----:|
|
||||
| 1. 형제 컬럼 확장 | ✅ 완료 | `run_column.py`, 각 `c{prefix}_*.png` | 6-1(0.993)✅ 6-2(0.997)✅ 8(0.630)❌ 9(0.886)❌ 10(0.818)❌ |
|
||||
| 2. SHUTDOWN | ✅ 완료 | `c6111_shutdown.py`, 각 컬럼 shutdown 플롯 | 6-1(4건 일관)✅ 6-2(5건 일관)✅ 9(22건, 3유형 분류) |
|
||||
| 3. Operator Assist | ✅ 완료 | `c6111_operator_assist.py`, 각 컬럼 advisory 리포트 | 6-1(92.2%)✅ 6-2(93.1%)✅ 8(84.3%)❌ 9(80.8%)❌ |
|
||||
| 4. C# Live 포팅 | ✅ 완료 | `SteamAdvisor.cs`/`Controller.cs`, `c6111_export_model.py` | 빌드 0 errors ✅ API `/api/steam/predict` |
|
||||
|
||||
## 공통 참조
|
||||
- 디코드/데이터: 플랜 §15. C-6111 토폴로지: §16.1. 방법론 교훈: §16.6(운전점), §16.7(OOD), §16.8(롤링).
|
||||
- 코드 시작점: `scripts/analysis/c6111_extract.py`(tag_frame, ROLES, classify_phases), `c6111_shadow.py`(SteamPredictor).
|
||||
93
docs/작업플랜-민감단온도-전환복귀제어.md
Normal file
93
docs/작업플랜-민감단온도-전환복귀제어.md
Normal file
@@ -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 하강램프 확장.
|
||||
36
docs/작업플랜-컬럼온도프로파일-이격모니터.md
Normal file
36
docs/작업플랜-컬럼온도프로파일-이격모니터.md
Normal file
@@ -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<PMA/PGMEA<EL).
|
||||
|
||||
## 차트 (ECharts)
|
||||
1. **세로 프로파일**: Y축=컬럼 위치(탑 T_D → 보텀 reb-A), X축=온도. 현재값 라인 + 기준밴드(회색 음영) 오버레이. 이격 시 라인이 밴드 밖 → 빨강 강조.
|
||||
2. **★진공압력 필수 동시표시**: 온도 프로파일은 진공에 종속(비점=f(압력)) → 진공 변하면 온도 전체 이동. 차트에 **PICA 진공압력을 반드시 병기**(게이지 또는 프로파일 옆 패널). 이격 판정 시 "진공 정상 하에서의 이격"인지 "진공 변동에 따른 정상 이동"인지 구분 — **진공 보정 후** 온도 이격을 평가. 진공 이탈 자체도 경보.
|
||||
3. **이격 지표**: 각 단 |현재−기준| → 막대/색상. ΔT(A-D) 스팬 표시(분리도). 스팬이 기준 이탈 시 경보.
|
||||
4. **시간추이**(옵션): 단별 온도 + 진공 trend(uPlot) + 기준선.
|
||||
|
||||
## 단계
|
||||
1. **기준 프로파일 산출**(Python): 안정구간에서 제품별 단별 온도 중앙값·σ → `c{prefix}_tempref.json`(제품 라벨별).
|
||||
2. **API**(C#): `GET /api/steam/tempprofile/{col}` — 현재 단별 온도(realtime) + 기준밴드 + 이격상태(정상/약화/붕괴/역전, AdvisoryResult.TempProfileState 재활용).
|
||||
3. **프론트**: ff.html 또는 steam pane에 프로파일 차트 추가. 실시간 갱신.
|
||||
4. **경보 연계**: 이격→§17 신뢰/품질 경보. STEAM advisory 신뢰도 게이트와 연동(이격 크면 advisory 보류).
|
||||
|
||||
## 검증기준
|
||||
- 6-1차 현재 온도가 기준밴드 내 표시(정상). 6-2차 5~6월 고온구간 = 밴드 이탈 강조(제품전환/실험구간 식별).
|
||||
- 오퍼레이터가 차트만 보고 이격 즉시 인지.
|
||||
|
||||
## 주의
|
||||
- ★진공 단위 확인 필요(PICA 113 표기 vs 50TORR — 메모리 미해결). 프로파일 해석 전 단위 확정.
|
||||
- 기준 프로파일은 **안정구간만**으로 학습(실험·SP방치 구간 제외, 메모리 §17.3).
|
||||
- TI-x111C 부재 컬럼(5차)은 대체단 처리.
|
||||
|
||||
## 기존 자산 재활용
|
||||
- `AdvisoryResult.Temps`/`TempProfileState`/`TempSpan`/`TempSpanRef`(이미 존재) — 이격 판정 로직 일부 구현됨. 차트는 이걸 시각화.
|
||||
178
docs/진단보고서-작업1-4.md
Normal file
178
docs/진단보고서-작업1-4.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 진단 보고서 — 학습형 제어 (작업 1~4)
|
||||
|
||||
> 진단 일시: 2026-06-05
|
||||
> 진단 규칙: `diagnosis-checklist.md` 8단계
|
||||
> 진단 범위: `scripts/analysis/` (run_column, shutdown, operator_assist, export_model) + `src/Infrastructure/Control/SteamAdvisor.cs` + `src/Hc900Crawler/Controllers/SteamAdvisorController.cs` + `Program.cs` 수정사항
|
||||
> 이전 보고서: `docs/진단보고서-학습형제어-1차.md` (작업지시서 문서 진단, c6111_*.py 기존 분석기)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 HIGH — 없음
|
||||
|
||||
---
|
||||
|
||||
## 🟠 MED
|
||||
|
||||
### M1. `SteamAdvisor.Predict()` 반환값 `Ood`/`InEnv` 하드코딩 오류
|
||||
|
||||
**문제**: PROD 모드 분기에서 `inEnv=false`여도 반환 `SteamAdvisoryResult`의 `Ood=false`, `InEnv=true`로 항상 고정. 클라이언트가 `confidence`가 아닌 `Ood`/`InEnv`로 판단하면 범위밖 입력을 구간내로 오인. `message`("⚠ 범위밖 입력")와 `confidence`("LOW_OOD")는 정상이나, 구조화 필드가 실제와 불일치.
|
||||
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:107` — `Ood = false, InEnv = true` 하드코딩. 의도된 값은 `Ood = !inEnv, InEnv = inEnv`.
|
||||
**영향**: `confidence`는 정상이나 `ood:false, inEnv:true`로 응답받은 API 클라이언트가 advisory를 수용할 위험. 또는 `confidence`만 보고 무시하더라도 데이터 파이프라인(로깅·추세)에 잘못된 envelope 정보 기록.
|
||||
**수정**: 1줄 변경.
|
||||
```csharp
|
||||
// before (SteamAdvisor.cs:107)
|
||||
Ood = false, InEnv = true,
|
||||
// after
|
||||
Ood = !inEnv, InEnv = inEnv,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2. `c6111_shutdown.py` 피드감소 시작점 탐색창 부족
|
||||
|
||||
**문제**: `shutdown_milestones()`의 feed 감소 시작 탐색이 600행(5시간) lookback으로 제한. P9/P10처럼 선행 피드감소 시간이 긴 shutdown 이벤트에서 탐색창 끝에 도달해도 feed 감소 미발견 → `feed_to_cutoff=299.5분`(최대값) 기록.
|
||||
**근거**: `scripts/analysis/c6111_shutdown.py:39` — `for j in range(co, max(0, co - 600), -1)`. 600행 = 5시간(30s 주기 기준). P9 5/22건이 299.5분 기록.
|
||||
**영향**: 해당 건의 `feed_to_cutoff`가 실제보다 짧게 추정되어 셧다운 레시피의 "피드감소→컷오프" 중앙값 왜곡(현재 55분 → 실제는 더 길 수 있음).
|
||||
**수정**: lookback을 `co - 1200`(10시간)으로 확장. 또는 feed 안정화 판정(`rolling std < threshold`)을 동적 종료 조건으로 추가.
|
||||
```python
|
||||
# c6111_shutdown.py:39
|
||||
for j in range(co, max(0, co - 1200), -1):
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M3. `SteamAdvisor.Predict()` NaN 입력 무방비
|
||||
|
||||
**문제**: `feed`·`product`·`tC` 인자가 `double.NaN`이면 선형 계산 `NaN` → `PolyVal(NaN)` → `NaN` → `Math.Clamp(NaN, 0, 100) = NaN` → HTTP 응답 `rec_OP: NaN`. 라이브 데이터에서 HC900 통신 끊김 등으로 quality 불량 시 NaN이 유입되면 API가 NaN을 그대로 반환.
|
||||
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:65-108` — Predict() 입구에 NaN 사전 차단 없음. `double.IsNaN()` 검증 생략.
|
||||
**영향**: `GET /api/steam/predict?feed=NaN&product=300&tC=85` → `{"rec_OP": NaN, ...}`. JSON에 `NaN`은 유효하지 않아 HTTP 500 또는 클라이언트 파싱 실패 유발.
|
||||
**수정**: Predict() 선두에 NaN 가드.
|
||||
```csharp
|
||||
if (double.IsNaN(feed) || double.IsNaN(product) || double.IsNaN(tC))
|
||||
return new SteamAdvisoryResult {
|
||||
Message = "입력값에 NaN 포함", Confidence = "N/A",
|
||||
Mode = "INVALID", Feed = feed, Product = product, TC = tC };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 LOW
|
||||
|
||||
### L4. Python `BASE` 경로 하드코딩 7개 파일
|
||||
|
||||
**문제**: `c6111_shutdown.py`·`operator_assist.py`·`export_model.py`·`prodmap.py`·`shadow.py`·`startup.py`·`rolling.py` 7개 파일에 `BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"` 절대경로 하드코딩. `run_column.py`만 동적(`os.path.dirname(os.path.abspath(__file__))`) 사용.
|
||||
**근거**: 각 파일의 `BASE` 상수 선언부.
|
||||
**영향**: 저장소 이동 시 plot 저장 실패. 단, `--data`/`--prefix` CLI 인자로 runtime override 가능.
|
||||
**수정**: `run_column.py`와 통일하여 `os.path.dirname(os.path.abspath(__file__))`로 변경.
|
||||
|
||||
### L5. `SteamAdvisor.PolyVal` 계수 부족 시 pass-through (무경고)
|
||||
|
||||
**문제**: `ValvePoly` 계수가 4개 미만이면 `coeffs[0]*x^3 + ...` 계산 시 IndexOutOfRange 전에 `if (coeffs.Count < 4) return x;`로 빠짐 → 로그 없이 입력값을 OP로 반환. export 불량 발견 불가.
|
||||
**근거**: `SteamAdvisor.cs:140-141`.
|
||||
**영향**: valve poly가 3차(4계수)가 아닌 모델을 로드해도 OP 계산 오류를 감지 불가.
|
||||
**수정**: 경고 로그 추가. `_logger.LogWarning(...)` 후 pass-through.
|
||||
|
||||
### L6. 컨트롤러 입력 검증 속성 누락
|
||||
|
||||
**문제**: `SteamAdvisorController`의 `[FromQuery] double feed` 등에 `[Required]`·`[Range]` 속성 없음. `POST /api/steam/predict` body `SteamPredictBody`에도 검증 속성 없음. 인자 생략 시 `0.0` 자동 전달.
|
||||
**근거**: `src/Hc900Crawler/Controllers/SteamAdvisorController.cs:23,33,41`.
|
||||
**영향**: 생략된 인자가 0으로 전달되어 비현실적인 권장 OP 산출.
|
||||
**수정**: `[FromQuery]` 파라미터에 `[Required]` 추가, `PredictRequest`에 `[Range(0, double.MaxValue)]` 속성.
|
||||
|
||||
### L7. `run_column.compare()` cutin 탐지 로직 중복
|
||||
|
||||
**문제**: `compare()` 함수 내에 `startup.py/detect_cutins`와 동일한 product 상향엣지 탐지 로직이 인라인 복사되어 있음. `startup.py`의 `detect_cutins` 로직이 변경되면 `compare()`의 결과와 불일치 발생.
|
||||
**근거**: `run_column.py:103-112` vs `c6111_startup.py:16-31`. 96% 동일 코드.
|
||||
**영향**: startup 탐지 알고리즘 개선 시 비교표가 업데이트되지 않아 검증 결과 혼선.
|
||||
**수정**: `compare()`에서 `from c6111_startup import detect_cutins` 호출로 대체.
|
||||
|
||||
### L8. `c6111_export_model.py` R² on training set
|
||||
|
||||
**문제**: `LinearRegression.score()`와 `GradientBoostingRegressor.score()`가 모두 `fit()`에 사용한 동일 데이터셋으로 평가. train/test 분할 없어 R²가 과대추정됨. export된 JSON의 `linear_r2`와 `gbm_r2`가 실제 일반화 성능보다 높게 표시.
|
||||
**근거**: `c6111_export_model.py:39,55`. `ops[FEATURES]`를 fit과 score에 동일 사용.
|
||||
**영향**: C# 측에서 `SteamModel.LinearR2`를 신뢰도 지표로 사용 시 과신. 현행 C# 코드는 R²를 비즈니스 로직에 사용하지 않아 실질 영향 없음.
|
||||
**수정**: `train_test_split`으로 분할 후 held-out R²도 export.
|
||||
|
||||
### L9. `run_column.py` DSN 평문 하드코딩
|
||||
|
||||
**문제**: PostgreSQL 연결문자열 `"host=localhost port=5432 dbname=field_hist user=postgres password=postgres"`가 소스코드에 평문 하드코딩. 버전관리 대상 파일.
|
||||
**근거**: `run_column.py:28`.
|
||||
**영향**: Dev DB 전용(로컬 PG16 컨테이너), 실제 민감정보 아님. 단, 리포지토리가 외부에 공개될 때 내부 DB 구조 노출.
|
||||
**수정**: 환경변수 `PG_DSN` 또는 `.env` 파일에서 읽도록 변경.
|
||||
|
||||
### L10. `c6111_operator_assist.py` unused import
|
||||
|
||||
**문제**: `import pickle`이 파일 상단에 선언되었으나 코드 어디에서도 `pickle`을 사용하지 않음.
|
||||
**근거**: `c6111_operator_assist.py:10`. 파일 내 `pickle.` 호출 0건.
|
||||
**영향**: 없음(컴파일 타임 영향 없음).
|
||||
**수정**: `import pickle` 삭제.
|
||||
|
||||
### L11. shutdown 컷오프 0건 시 플롯 루프 비정상
|
||||
|
||||
**문제**: `detect_cutoffs()`가 빈 리스트 반환 시 `for k, w in enumerate(windows)` 루프가 0회 반복 → plot이 비어 있음. matplotlib 경고(`UserWarning: No artists with labels...`) 발생.
|
||||
**근거**: `c6111_shutdown.py:110-118`. `windows`가 빈 리스트일 때 plot만 생성되고 아무것도 그려지지 않음.
|
||||
**영향**: P10 등 shutdown 이벤트 없는 컬럼에서 빈 플롯 파일 생성. 기능상 문제는 없으나 사용자 혼란.
|
||||
**수정**: `if not windows: print(" [skip] shutdown 이벤트 없음"); return` 추가.
|
||||
|
||||
### L12. `PredictAsync` 동기 구현 — CancellationToken 미사용
|
||||
|
||||
**문제**: `PredictAsync()`가 항상 `Task.FromResult(Predict(...))`로 동기 실행. `CancellationToken` 파라미터를 전혀 사용하지 않음.
|
||||
**근거**: `SteamAdvisor.cs:112-116`.
|
||||
**영향**: 현재는 항상 동기(fast operation)여서 실질 문제 없음. 비동기 체인에서 호출 시 불필요한 Task 할당만 발생.
|
||||
**수정**: `Task.FromResult` 대신 `ValueTask.FromResult` 사용하거나, 실제 async 경로(DB 조회 등) 추가 시 token 전파.
|
||||
|
||||
---
|
||||
|
||||
## 교차 검증 통과 내역
|
||||
|
||||
| # | 항목 | Q1(기수정?) | Q2(타레이어?) | Q3(의도?) | Q4(재현?) | 최종 |
|
||||
|---|------|:----------:|:------------:|:--------:|:--------:|:----:|
|
||||
| M1 | Ood/InEnv 하드코딩 | No | No | No(실수) | Yes | MED |
|
||||
| M2 | feed_start 299.5 | No | No | No | Yes | MED |
|
||||
| M3 | NaN 무방비 | No | No | No | Yes | MED |
|
||||
| L4 | BASE 하드코딩 | No | 부분완화 | No | Yes | LOW |
|
||||
| L5 | PolyVal 무경고 | No | - | No | 조건부 | LOW |
|
||||
| L6 | Controller 검증누락 | No | No | No | 조건부 | LOW |
|
||||
| L7 | cutin 중복 | No | 독립함수 | No | 조건부 | LOW |
|
||||
| L8 | R² train-only | No | 정보용 | No | No | LOW |
|
||||
| L9 | DSN 평문 | No | Dev전용 | No | No | LOW |
|
||||
| L10 | pickle 미사용 | No | - | No(생략) | No | LOW |
|
||||
| L11 | shutdown empty | No | - | No | Yes | LOW |
|
||||
| L12 | async 동기 | No | - | 설계(주석) | No | LOW |
|
||||
|
||||
---
|
||||
|
||||
## 자가 검증
|
||||
|
||||
- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킴
|
||||
- [x] MED 3건 모두 재현 가능한 시나리오 서술
|
||||
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함
|
||||
- [x] 수정 예시가 현재 코드에 아직 적용되지 않은 내용
|
||||
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다" 혼동하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 변경 요약
|
||||
|
||||
| 심각도 | 건수 | 즉시 수정 권장 | 상태 |
|
||||
|:-------:|:---:|:--------------:|:----:|
|
||||
| HIGH | 0 | — | — |
|
||||
| MED | 3 | M1(Ood): **즉시**, M3(NaN): **즉시**, M2(feed_start): 분석 재실행 필요 | **✅ 전항 수정 완료** |
|
||||
| LOW | 8+1 | 리팩터링 시 일괄 | **✅ 4건 수정 완료** |
|
||||
|
||||
### 수정 내역 (2026-06-05)
|
||||
|
||||
| 항목 | 상태 | 변경 내용 |
|
||||
|:----|:----:|:---------|
|
||||
| **M1** Ood/InEnv 하드코딩 | ✅ 수정 | `SteamAdvisor.cs:107` — `Ood=false,InEnv=true` → `Ood=!inEnv,InEnv=inEnv` |
|
||||
| **M2** feed_start lookback 부족 | ✅ 수정+분석재실행 | `c6111_shutdown.py:45` — 600→1200행 확장. P9 `feed_to_cutoff` 중앙값 55→99분 개선 (6/22건은 여전히 599.5로 10h ceiling 도달) |
|
||||
| **M3** NaN 입력 무방비 | ✅ 수정 | `SteamAdvisor.cs:69-71` — `double.IsNaN` 가드 추가 |
|
||||
| **L5** PolyVal 무경고 | ✅ 수정 | `SteamAdvisor.cs:142` — 계수 부족 시 `_logger.LogWarning` |
|
||||
| **L10** pickle 미사용 | ✅ 수정 | `c6111_operator_assist.py:10` — `import pickle` 삭제 |
|
||||
| **L11** shutdown empty plot | ✅ 수정 | `c6111_shutdown.py:99-101` — 컷오프 0건 시 조기 return |
|
||||
| **L12** async misleading | ✅ 수정 | `SteamAdvisor.cs:112` — `Task`→`ValueTask`, `CancellationToken` 연동 |
|
||||
| **L4** BASE 하드코딩 | ⬜ 보류 | 8개 파일 — 추후 리팩터링 |
|
||||
| **L6** Controller 검증 누락 | ⬜ 보류 | `[Required]`·`[Range]` 속성 |
|
||||
| **L7** cutin 중복 | ⬜ 보류 | `from c6111_startup import detect_cutins` |
|
||||
| **L8** R² train-only | ⬜ 보류 | train/test 분할 |
|
||||
| **L9** DSN 평문 | ⬜ 보류 | 환경변수 전환 |
|
||||
708
docs/학습형제어-오퍼레이터모방-플랜.md
Normal file
708
docs/학습형제어-오퍼레이터모방-플랜.md
Normal file
@@ -0,0 +1,708 @@
|
||||
# 학습형 제어 (오퍼레이터 수동조작 모방) — 설계 플랜
|
||||
|
||||
> 상태: 설계(design) 단계. 코드 착수 전.
|
||||
> 작성 맥락: PID hunting 회피를 위해 현장 MANUAL 운전 이력을 학습해 OP를 자동 산출하는 제어기 설계 논의 보존.
|
||||
|
||||
---
|
||||
|
||||
## 0. 오리엔테이션 — 다음 세션의 나에게 (READ FIRST)
|
||||
|
||||
> 이 블록은 다음에 이 문서를 여는 에이전트(=미래의 나)가 **지금과 동일한 사고 상태로 즉시 몰입**하기 위한 것이다. 아래를 먼저 읽고 §1~§13으로 들어가라.
|
||||
|
||||
### 0.1 이 문서가 뭔가
|
||||
HC900 프로젝트(`/home/windpacer/projects/hc900_ax`, CLAUDE.md 참조)의 **신규 작업 줄기**에 대한 설계 플랜이다. 본체 프로젝트는 "HC900 Modbus→C++ gateway→gRPC→C# 크롤러→PostgreSQL"로 Experion OPC UA 경로를 대체하는 일이고, **이 문서는 그 위에 얹는 "학습형 제어기"** 설계다. 아직 **코드는 한 줄도 안 짰다.** 전부 설계 합의 단계.
|
||||
|
||||
### 0.2 사용자가 진짜 풀려는 문제 (가장 중요 — 이걸 잊으면 길을 잃는다)
|
||||
- 현장(6차 플랜트)은 **PID를 못 믿어서 거의 모든 루프를 MANUAL로 돌리고 오퍼레이터가 손으로 OP를 잡는다** (진공제어만 PID/AUTO 예외).
|
||||
- 사용자가 **가장 두려워하는 단 하나 = hunting(지속 진동)**. PID 교란성 발진. 이 공포가 모든 설계 결정의 1순위 제약이다.
|
||||
- 따라서 목표는 "PID를 잘 튜닝"이 **절대 아니다.** **오퍼레이터의 수동 조작을 학습해 자동화**하되, **구조적으로 안 떠는** 제어기를 만드는 것. 평가 기준은 "잘 튜닝된 PID"가 아니라 **"현재 신뢰받는 오퍼레이터"**.
|
||||
|
||||
### 0.3 이미 굳은 핵심 결정 (재론하지 말 것 — 사용자와 합의됨)
|
||||
1. **접근법 = 정상상태 맵**: `OP_ss = f(정착 PV, 부하변수)`. 원시 behavior cloning(`OP=f(PV,SP)`) 아님. (§4)
|
||||
2. **SP를 정답으로 안 쓴다**: 현장 SP 신뢰도 ~80%(MANUAL이라 방치 가능). **정착 PV = de facto target**으로 사용. (§4)
|
||||
3. **안 떠는 이유 = 전향맵(피드백 없음) + 데드밴드 + dwell + rate-limit.** stiction은 어떤 제어로도 못 고치며 "덜 움직이기"만이 답. (§5)
|
||||
4. **검증 = shadow 모드(실플랜트, write 안 함) > 시뮬레이터.** 시뮬은 선택. (§6)
|
||||
5. **로드맵 = 학습 → shadow → operator-assist → guarded closed-loop**, 이상 시 fallback은 PID가 아니라 **오퍼레이터에게 반환**. (§7)
|
||||
6. **RL/imitation은 실증 부족(sim2real 미해결)으로 1차 투입 부적합.** 실증된 건 **MPC(2단 구조)**·MFAC·퍼지/전문가 supervisory. 우리 "정상상태 맵+gentle"이 마침 **MPC 2단 구조(LP 정상상태 타깃 + 동적 move-suppress)와 동형** → de-risk. (§11·§12)
|
||||
7. **착수 1순위 = 기둥 A(루프 헬스 모니터링, APROMON류).** dump만 있으면 즉시 가능하고 파일럿 루프를 데이터로 객관 선정해준다. (§13.1)
|
||||
8. **개발은 6차 플랜트 기준으로 먼저, 이후 차수 확장.** 이유: 6차는 **HC900 통신이 살아있어**(현재 live 값은 가공이라도) 실 제어경로(shadow→assist→closed) 테스트 가능. 6·8·9·10차는 **측류 반도체용 솔벤트**로 동일 유형 → 6차용을 만들면 8·9·10에 그대로 확장. 1·2차는 **측류 아닌 2컬럼 경질/중질 제거 일반 증류**라 별도. (§15.9)
|
||||
9. **★개발 범위 = 6-1차 단독★** (6-1/6-2 독립운전). 6-1차 활성 제어루프 6개: **TICA-6111A**(파일럿) + **FICQ-6101/6113/6114/6116/6118**. 제외: PICA-6111(진공), LICA(사장). 6-1차용 완성 → 6-2·8·9·10 복제. 피처는 **6-1차 내부 변수만** 사용. (§15.11) 컬럼 토폴로지·센서위치·스트림역할은 §16.1(기존 ff_config + 사용자 도메인)에 권위 정의.
|
||||
10. **★학습 대상 3종★**: ①정상생산 제어(정상상태 맵, 현재) + ②START-UP 절차 + ③SHUTDOWN 절차(②③=시퀀스/절차 모방, 안전 자동화, 추후). 6모드 상태분류기가 ①필터+②③골격 이중역할. **과도구간 데이터 버리지 말 것.** (§16.4)
|
||||
|
||||
### 0.4 데이터 자산 (반입·복원 완료 2026-06-05 — 상세 §15)
|
||||
- **dump 반입 완료**: 현장(신암정유) DB `shinam`(PG9.5) → 별도 DB **`field_hist`**(우리 PG16) 복원 완료. 라이브 `iiot_platform`/`hc900` 안 건드림.
|
||||
- 기간 **2026-02-05~06-05 (~4개월), 간격 주로 30초**. WIDE 포맷: `cont001~017` + `ptlist/tblist/mapping` 디코드. 시계열 복원 규칙·검증은 §15.3.
|
||||
- **OP(106)·SP(114)·PV(456) 모두 존재 → PV+SP+OP 완비 루프 104개.** 학습 대상 확보(make-or-break 통과).
|
||||
- ⚠️ **MODE 태그는 없음** (100% MANUAL이라 미기록 추정). → **진공제어(PID) 루프 식별·제외 방법이 미해결** (§15.5).
|
||||
- ★통찰★: MANUAL OP 계단조작 = 개루프 step 데이터 → 시스템 식별 유리. bump test 보조. (§13.0)
|
||||
- 30초 해상도: 느린 루프(온도/레벨) OK, 빠른 루프(유량/압력) 동역학·stiction은 aliasing 한계.
|
||||
|
||||
### 0.5 지금 막혀있는 곳 / 다음 행동
|
||||
- **데이터 확보됨**(`field_hist`). 다음: ① (선택) cont 테이블 하이퍼테이블+압축 → ② **§13.1 기둥 A KPI**(진동지수·IAE·OP travel·stiction) → ③ 파일럿 루프 선정 → ④ SP 신뢰도 실측 + 정상상태 세그먼테이션 → ⑤ `OP_ss=f(정착PV,부하)` 맵.
|
||||
- **사용자 확인 대기**: 진공(PID) 루프를 태그 패턴/지식으로 어떻게 가려낼지 (§15.5).
|
||||
- 미해결 질문 전체는 §10·§15.5.
|
||||
- ★**남은 작업(형제확장·shutdown·operator-assist·live포팅)은 `docs/작업지시서-학습형제어-다음단계.md`에 실행 지시서로 정리됨.** ①생산제어·②startup은 6-1차 오프라인 완료(§16.6~16.10).
|
||||
|
||||
### 0.6 톤/태도 (사용자와의 작업 방식)
|
||||
- 사용자는 한국어로 빠르게 핵심만 던진다. **장황한 옵션 나열 말고 추천을 줘라.** 단, 안전·hunting 관련해선 트레이드오프를 정직하게.
|
||||
- 이 문서는 **사용자가 "기록/플랜에 남겨"라고 하면 즉시 갱신**해온 살아있는 문서다. 큰 결정이 바뀌면 §0.3을 먼저 고쳐라.
|
||||
|
||||
### 0.7 관련 포인터
|
||||
- 본체 지침: `/home/windpacer/projects/hc900_ax/CLAUDE.md`
|
||||
- 메모리: `learned-control-operator-imitation.md` (이 줄기), `mode-write-mechanism.md`(OP/MODE 쓰기 메커니즘), `project_overview.md`
|
||||
- 기능 벤치마크 원문: `docs/PITOPS-브레인스토밍-웹페이지복사.md`
|
||||
- 기존 FF 자산 재활용: `FeedforwardWriteGuard`(상하한/rate-limit), `FfTrackingStore`(모델 결정 로깅), `...AdvisorService` 네이밍
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 / 문제 정의
|
||||
|
||||
- 현장에서 **PID가 제대로 안 쓰임** → 오퍼레이터가 루프를 **MANUAL로 돌려놓고 손으로 OP를 조정**.
|
||||
- 가장 두려워하는 것: **PID 교란에 의한 hunting(지속 진동)**.
|
||||
- 따라서 목표는 "PID를 더 잘 튜닝"이 **아니라**, **오퍼레이터의 수동 조작을 학습해 자동화**하는 것. 비교 기준은 "잘 튜닝된 PID"가 아니라 "현재 신뢰받는 오퍼레이터".
|
||||
- 오퍼레이터는 본질적으로 hunting을 안 함 → **오퍼레이터를 모방하면 "안 떠는" 성질을 그대로 물려받음**.
|
||||
|
||||
## 2. 목표
|
||||
|
||||
- 현장 실 운전데이터로 `OP`(제어출력)를 학습 → 그 모델로 제어.
|
||||
- **hunting 없이** 동작하는 것이 최우선 제약.
|
||||
- 단계적·안전 우선 투입 (사람을 루프에 두고 시작).
|
||||
|
||||
## 3. 데이터 자산 (확정)
|
||||
|
||||
- 현장 DB 서버에 **PostgreSQL, 30초 주기, 몇 달치** 이력 존재. `pg_dump`으로 반입 예정.
|
||||
- **OP 로깅됨** ✅ (제어출력 학습 가능 — make-or-break 통과)
|
||||
- **100% MANUAL 운전 데이터** (진공제어만 PID/AUTO 예외) → 통째로 *오퍼레이터 시연 데이터*. MODE 필터링 거의 불필요.
|
||||
- **모든 플랜트 변수 존재**: 온도, 압력, 유량, 레벨, 진공압력 → `f(목표, 부하)`의 입력 확보.
|
||||
- **SP 신뢰도 ~80%**: MANUAL이라 오퍼레이터가 SP를 자기 목표값으로 정확히 설정했는지 불확실(20%는 stale 가능).
|
||||
|
||||
### 해상도 주의 (30초)
|
||||
| 루프 종류 | 30초로 가능 |
|
||||
|---|---|
|
||||
| 온도/레벨/조성 (τ 분~시간) | 동역학·stiction 한계주기 ✅, 시스템ID ✅ |
|
||||
| 유량/압력 (τ 초) | 빠른 동역학·stiction은 **aliasing으로 소실** ❌ (정상상태 맵은 학습 가능) |
|
||||
|
||||
→ 30초 해상도는 **gentle 제어기(정상상태 맵 + 데드밴드)** 방향을 지지함. 공격적 RL/고정밀 동역학 시뮬에는 부족.
|
||||
|
||||
## 4. 핵심 전략: 정상상태 운전 맵 (Steady-State Map)
|
||||
|
||||
원시 behavior cloning(`OP=f(PV,SP)`) 대신 **정상상태 맵**을 학습:
|
||||
|
||||
```
|
||||
OP_ss = f(정착 PV, 부하변수) ← SP를 아예 안 거침
|
||||
```
|
||||
|
||||
근거:
|
||||
- **SP 80% 신뢰도 문제 우회** — MANUAL 정상상태에서 오퍼레이터가 OP를 잡아두고 PV가 안정돼 있으면 **그 PV가 곧 진짜 목표**(SP 입력값과 무관). 정착 PV = de facto target.
|
||||
- **구조적으로 안 뜸** — 전향(feedforward) 맵은 피드백 루프가 없어 자기 혼자 진동 불가.
|
||||
- **설명 가능** — "이 조건이면 밸브는 이 위치"라는 오퍼레이터 지식의 모델화. 블랙박스 NN보다 현장 수용성↑.
|
||||
|
||||
### SP의 역할 = 보조
|
||||
- SP 신뢰도는 **루프별로 데이터로 실측**: 정상상태 구간마다 `|SP − 정착PV|` 분포 → 작으면 신뢰, 지속적으로 벌어지면 stale → 그 구간 폐기.
|
||||
- "80%"를 전역으로 받지 말고 루프별 차등 적용.
|
||||
- 전이(transition) 구간의 목적지 힌트로만 SP 보조 사용(PV와 일치할 때 한정).
|
||||
- **배포 시 기준값은 SP 레지스터가 아니라 오퍼레이터/레시피 입력 목표**에서.
|
||||
|
||||
## 5. Hunting — 원인과 방지
|
||||
|
||||
### 원인 4가지
|
||||
| 원인 | 증상 | 처방 |
|
||||
|---|---|---|
|
||||
| ① 과도한 게인 | 감쇠 안 되는 진동 | 게인↓ (느린 제어) |
|
||||
| ② 데드타임/지연 | 주기 ≈ 2×데드타임 | **dwell time ≥ 데드타임** |
|
||||
| ③ 밸브 stiction | OP 톱니/PV 삼각파 **한계주기** | **어떤 PID로도 못 잡음** — 데드밴드로 가두고 거의 안 움직이기 |
|
||||
| ④ PV 노이즈 | OP 고주파 chattering | 데드밴드 + 필터 |
|
||||
|
||||
③ stiction이 오퍼레이터가 MANUAL로 도망가는 주 원인일 가능성 높음. 사람은 stiction 영역에서 OP를 가만히 둠.
|
||||
|
||||
### 학습 제어기가 안 떠는 이유 (정밀)
|
||||
- **전향 맵 부분** = 피드백 없음 → 절대 자기발진 불가.
|
||||
- **느린 트림(feedback) 부분** = 반드시 **데드밴드 + dwell + rate-limit**으로 이산·저속화 → ②③④ 동시 차단.
|
||||
- "안 떠는 건 데드밴드/dwell이 만든다."
|
||||
|
||||
### Hunting 자동 감지기 (안전 폴백 트리거)
|
||||
- 최근 N분간 오차 `(SP−PV)` 부호 반전 ≥ k회, 또는 OP 방향 반전 ≥ k회 + 진폭 초과
|
||||
- 감지 시 → **OP 동결 → 오퍼레이터에 반환** + 경보
|
||||
|
||||
## 6. 검증 전략: Shadow 모드 > 시뮬레이터
|
||||
|
||||
실 MANUAL 데이터가 있으므로 **시뮬레이터는 더 이상 전제조건이 아님**:
|
||||
- 학습: 실데이터로 바로 → 시뮬 불필요.
|
||||
- 검증: **shadow 모드(실플랜트)가 더 나은 검증기** — 플랜트가 이미 MANUAL로 도니, 모델이 실시간 OP를 *예측만* 하고 오퍼레이터 실제 조작과 비교. write 안 하니 위험 0, 진짜 오퍼레이터 상대 정직한 검증, distribution shift도 즉시 노출.
|
||||
- 시뮬은 **선택사항**(hunting 감지기·fallback을 드문 시나리오로 스트레스 테스트할 때만). 필요 시 같은 데이터로 system-ID해 보정(느린 루프 한정).
|
||||
|
||||
## 7. 로드맵 (단계적·안전 우선)
|
||||
|
||||
```
|
||||
① 실 MANUAL 데이터로 정책 학습 OP_ss = f(정착PV, 부하…)
|
||||
↓
|
||||
② Shadow 모드 (실플랜트, write 안 함)
|
||||
오퍼레이터 vs 모델 실시간 비교 — 위험 0
|
||||
↓
|
||||
③ Operator-assist "권장 OP=X" 화면 표시, 사람이 누름 (신뢰 구축)
|
||||
↓
|
||||
④ Guarded closed-loop 데드밴드+dwell+rate-limit+상하한 + hunting 감지
|
||||
↓
|
||||
이상 시 fallback = 오퍼레이터에게 반환 (PID 아님 — PID는 못 믿음)
|
||||
```
|
||||
|
||||
기존 자산 활용: `FeedforwardWriteGuard`(상하한/rate-limit), `FfTrackingStore`(모델 결정 로깅), `...AdvisorService` 네이밍이 자문 모드에 부합.
|
||||
|
||||
## 8. 리스크 / 확인 항목
|
||||
|
||||
- **Distribution shift (imitation 고질병)**: 학습 정책이 오퍼레이터가 안 가본 상태에 들어가면 오차 누적. 완화책 = 몇 달치 넓은 커버리지 + gentle 정책으로 분포 근처 유지 + shadow/assist 선행.
|
||||
- **OP 변경 빈도 측정**: 드물게 계단식이면(대부분 그럴 것) 정책이 사실상 정적 맵 = 공짜 gentle 제어기.
|
||||
- **off-spec 구간 학습 위험**: 오퍼레이터가 불량값 잡고 있던 구간. 품질/생산 데이터로 필터, 없으면 shadow+assist 단계에서 사람이 거름.
|
||||
- **Causality/confounding**: 오퍼레이터가 DB 밖 정보(육안, 랩 샘플, 전화)로 움직였을 수 있음. 잔차/피처 분석으로 "가용 변수가 OP를 얼마나 설명하나" 측정.
|
||||
- **OP 레지스터 RW 확인**: register-map에서 대상 루프 OP의 access.
|
||||
|
||||
## 9. 다음 스텝 (dump 반입 후 즉시)
|
||||
|
||||
1. dump을 **별도 스키마**(예: `field_history`)로 restore — 라이브 `hc900` 안 건드림. 용량 ≈ 수억 행(2000태그×30초×3개월), Timescale 압축 권장.
|
||||
2. 루프별 **SP 신뢰도 실측** (`|SP−정착PV|` 분포)
|
||||
3. **정상상태 세그먼트 추출** (dPV/dt≈0, dOP/dt≈0)
|
||||
4. **`OP_ss = f(정착PV, 부하)` 맵 학습** + 잔차/피처 분석
|
||||
5. **stiction 1차 진단** (느린 루프 한정)
|
||||
6. **파일럿 루프 1개 선정** (느리고 안정적인 온도/레벨 우선, 빠른 유량은 나중)
|
||||
|
||||
## 10. 미해결 질문
|
||||
|
||||
- [ ] dump 반입 시점?
|
||||
- [ ] 현장 DB **스키마(테이블·컬럼명)** — 쿼리 설계용
|
||||
- [ ] 파일럿 후보: "오퍼레이터가 제일 안정적으로 잘 잡는 루프"는? (현장 감 + 데이터 양쪽)
|
||||
- [ ] 품질/생산 데이터 존재 여부 (off-spec 필터용)
|
||||
- [ ] 대상 루프 OP가 register-map에서 RW인지
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고 문헌 — 실증된 제어 기법 (실증·인용 1·2위)
|
||||
|
||||
> 선정 기준: **실플랜트 실증 이력 + 인용수**. RL/imitation 계열은 논문은 많으나
|
||||
> 실플랜트 실증이 빈약(sim-to-real 미해결)해 1차 투입 부적합 → 아래 두 기법이 검증된 선택지.
|
||||
|
||||
### 1위 — 산업용 MPC (Model Predictive Control)
|
||||
- **문헌**: S.J. Qin & T.A. Badgwell, *A Survey of Industrial Model Predictive Control Technology*, Control Engineering Practice **11** (2003) 733–764.
|
||||
- **링크**: https://www.sciencedirect.com/science/article/abs/pii/S0967066102001867
|
||||
- **요지**:
|
||||
- 상용 MPC(선형·비선형)를 **벤더 실사 데이터** 기반으로 정리한 서베이. 제어 분야 **최다 인용급(수천 회)**.
|
||||
- 1980년대 **DMC(Dynamic Matrix Control, Shell/Cutler)·IDCOM** 이래 정유·석화에 **수천 개 실제 설치** — 비-PID 고급제어의 사실상 산업 표준.
|
||||
- 핵심 특성: **미래 예측 + 제약(상하한·rate) 명시적 준수 + 다변수 처리**. 잘못 튜닝된 PID보다 hunting 위험이 낮고, 운전 한계를 직접 지킴.
|
||||
- **우리 적용**:
|
||||
- 30초 운전데이터로 **느린 루프(온도/레벨)는 동역학 모델 ID 가능** → MPC 구성 가능. 빠른 유량은 ID 한계(해상도 부족).
|
||||
- 단점: 좋은 동역학 모델이 전제. 우리의 "정상상태 맵 + 데드밴드 트림"은 이 모델 의존도를 낮춘 실용적 절충.
|
||||
|
||||
### 2위 — MFAC (Model-Free Adaptive Control)
|
||||
- **문헌**: Zhongsheng Hou & Shangtai Jin, *Model Free Adaptive Control: Theory and Applications*, CRC Press (2013). 원개념 Hou, 1994.
|
||||
- **링크**: https://www.taylorfrancis.com/books/mono/10.1201/b15752/model-free-adaptive-control-zhongsheng-hou-shangtai-jin
|
||||
- **요지**:
|
||||
- **모델 없이 입출력(I/O) 측정 데이터만으로** 제어기 설계·안정성 분석. 핵심은 **동적 선형화(dynamic linearization, pseudo-partial-derivative)** — 매 시점 등가 선형모델을 데이터로 추정.
|
||||
- 미지의 이산시간 **비선형·시변** 시스템 대상. 1차 원리/system-ID로 모델링이 어려운 복잡 공정에 적합.
|
||||
- 산업 적용 사례 다수(주로 중국). MFAC + 예측제어 + 반복학습제어(ILC)로 확장.
|
||||
- **우리 적용**:
|
||||
- "PID·모델 없이 데이터로 제어"라는 우리 취지에 직접 부합. MPC처럼 사전 모델이 필요 없음.
|
||||
- 단 적응 게인이 들어가므로 **hunting 방지 가드(데드밴드·dwell·rate-limit)와 병행** 필요.
|
||||
|
||||
### 우리 방향과의 관계
|
||||
우리가 설계한 **"정상상태 맵 + gentle 제어(데드밴드/dwell)"** 는 화려한 RL보다 **MPC/supervisory 계보(수십 년 실증)에 가깝다.** 즉 방향이 검증된 쪽에 붙어 있어 de-risk됨. 추가로 **퍼지/전문가 시스템 supervisory control**(시멘트 킬른·철강 등)은 "오퍼레이터 지식→자동화"를 수십 년 실증한 가장 직접적 계보 — 별도 사례조사 가치 있음.
|
||||
|
||||
---
|
||||
|
||||
## 12. 1위 심화 — 산업용 MPC 정밀 분석
|
||||
|
||||
> ★핵심 발견★: 상용 MPC는 **2단 구조**이며, 우리가 독립적으로 설계한
|
||||
> "정상상태 맵 + gentle 동적 제어"가 **산업 표준 아키텍처와 동형**.
|
||||
|
||||
### 12.1 동작 원리 — Receding Horizon
|
||||
매 주기: ① 모델로 미래구간(P) PV 예측(미래 OP 시퀀스 M의 함수) → ② 비용
|
||||
`J = Σ(SP−PV_예측)² + λ·Σ(ΔOP)²` 을 제약(`OP_min/max`, `|ΔOP|≤rate`, `PV 한계`)
|
||||
하에 최소화 → ③ **첫 수만 적용**, 다음 주기 재측정 후 재최적화.
|
||||
PID는 "현재 오차"만, **MPC는 "미래 궤적"을 본다**는 게 본질 차이.
|
||||
|
||||
### 12.2 ★상용 MPC = 2단 구조 (우리 설계와 동형)★
|
||||
Aspen DMC3 / Honeywell Profit Suite 등 상용 패키지 구조:
|
||||
```
|
||||
[상층] LP/정상상태 최적화 → "어디로" (정상상태 OP/PV 타깃)
|
||||
[하층] 동적 MPC → "어떻게 부드럽게" (move 최소화)
|
||||
```
|
||||
→ **우리 `OP_ss=f(정착PV,부하)` 맵 = 상층 LP의 데이터 기반판. 데드밴드/dwell 트림
|
||||
= 하층 동적 MPC의 경량판.** 우리 설계가 수십 년 검증된 표준 골격과 같음 = de-risk.
|
||||
|
||||
### 12.3 왜 PID보다 hunting이 적나
|
||||
| Hunting 원인 | MPC 처리 |
|
||||
|---|---|
|
||||
| ② 데드타임 | 모델이 지연응답을 명시 예측 → "효과 볼 때까지 대기". **MPC 최대 강점** |
|
||||
| ① 과도 게인 | move suppression λ로 공격성 조절 (λ↑ = gentle = 오퍼레이터처럼) |
|
||||
| 다변수 간섭 | 모델이 상호작용 포함 → decoupling 내장 |
|
||||
| 제약/windup | OP·rate 한계를 최적화에 명시 → windup성 진동 없음 |
|
||||
|
||||
단 **③ stiction(한계주기)은 MPC도 못 고침** (공격적 MPC는 오히려 자극).
|
||||
처방 동일: **λ↑ + 데드밴드** → 데드타임·stiction 동시 대응.
|
||||
|
||||
### 12.4 핵심 제약 — 모델 필요 + 식별 문제 (최대 관문)
|
||||
- 상용 MPC = step-response/FIR(DMC) 또는 저차 전달함수(FOPDT) 모델 사용.
|
||||
- 식별엔 **여기성(excitation)** 필요 — 통상 step/PRBS **bump test**.
|
||||
- **우리 데이터의 한계**:
|
||||
- operator MANUAL = **외란에 반응한 폐루프 데이터** → 입력·외란 상관 → 편향 모델 위험(폐루프 식별법 필요).
|
||||
- **30초 해상도** → 느린 루프(온도/레벨) ID 가능, **빠른 유량 부족**.
|
||||
- → **느린 파일럿 루프 + 가동 중 소수 bump test** 보강이 현실적.
|
||||
|
||||
### 12.5 벤더 실증 계보 (Qin & Badgwell)
|
||||
- **DMC** (Cutler & Ramaker, Shell 1980) → AspenTech → **DMCplus / DMC3**
|
||||
- **Honeywell RMPCT** (Robust Multivariable Predictive Control Technology) + Profimatics PCT → **Profit Controller / Profit Suite**
|
||||
- **IDCOM/HIECON** (Adersa) — 최초 상용 MPC
|
||||
- 정유·석화 중심 **수천 건 실제 설치**.
|
||||
- **HC900이 Honeywell** → Profit 계보와 개념적 한 식구(단 Profit은 Experion/TPS급, HC900 소형 → 자체 구현 필요).
|
||||
|
||||
### 12.6 우리 시스템 적용 아키텍처
|
||||
```
|
||||
C# 크롤러 = MPC 호스트
|
||||
PV/부하 읽기(gRPC 캐시 ~1s)
|
||||
→ [상층] 데이터 정상상태 맵 → OP_ss 타깃
|
||||
→ [하층] move-suppressed QP → ΔOP 1수
|
||||
→ WriteTag로 OP (FeedforwardWriteGuard 상하한/rate)
|
||||
1초 주기 = 느린 루프엔 충분
|
||||
로드맵 동일: shadow → assist → guarded closed-loop
|
||||
```
|
||||
|
||||
### 12.7 갭/리스크
|
||||
1. **모델 ID**: 폐루프·외란상관·30초 → bump test는 *보조*. **MANUAL OP step ≈ 개루프 데이터**라 기존 데이터만으로 식별 우선 시도 (→ §13.0 참조).
|
||||
2. **계산**: .NET QP 솔버(소형·1초면 충분), 무제약 DMC+클램프로 간이화 가능.
|
||||
3. **설명가능성**: MPC는 룩업맵보다 덜 직관 → assist 모드 + 제약/타깃 화면표시로 보완.
|
||||
4. **유지보수**: 모델 드리프트 → 주기적 re-ID.
|
||||
5. **stiction**: MPC 미해결 → 데드밴드 레이어 필수.
|
||||
|
||||
### 12.8 권고
|
||||
- **Full 상용형 MPC를 처음부터 가지 말 것** (모델ID·계산·신뢰 부담).
|
||||
- 대신 **2단 구조 경량화**: `정상상태 맵(데이터)` + `강한 move-suppress 예측 트림`
|
||||
= **"우리 데이터·제약에 맞춘 경량 MPC"** (산업 표준 골격과 일치).
|
||||
- 파일럿 = **느린 루프(온도/레벨)** — 기존 MANUAL 데이터로 식별 우선, bump test는 필요 시 보조.
|
||||
- 착수 순서 추천: **(a) 상층 정상상태 맵 먼저**(데이터만으로 가능) → 이후 **(b) 하층 모델 식별**(기존 데이터 우선, bump test 보조).
|
||||
|
||||
### 12.9 참고 링크
|
||||
- Qin & Badgwell 서베이: https://www.sciencedirect.com/science/article/abs/pii/S0967066102001867
|
||||
- 상용 MPC 2단(LP+동적) 구조 설명: https://www.picontrolsolutions.com/blog/picontrol-closed-loop-time-domain-technology-helps-on-aspen-dmc-honeywell-rmpct-profit-emerson-and-other-mpc-software/
|
||||
- Honeywell Profit Controller / RMPCT 개요: https://www.scribd.com/document/90410821/Profit-Controller-Rmpct-Overview
|
||||
|
||||
---
|
||||
|
||||
## 13. 기능 로드맵 — PiControl/PITOPS 벤치마크
|
||||
|
||||
> 출처: `docs/PITOPS-브레인스토밍-웹페이지복사.md` (PiControl 제품 페이지).
|
||||
> PiControl 3제품 = 3개 기능 기둥. **제품을 사는 게 아니라 핵심 아이디어를 C# 스택에 내재화.**
|
||||
> - **APROMON** = 루프 성능 모니터링(CLPM)
|
||||
> - **PITOPS** = 다변수 폐루프 시스템 식별 + 다목적 PID 튜닝 + APC 설계
|
||||
> - **COLUMBO** = MPC 모델 유지보수(오프라인 Excel)
|
||||
|
||||
### 13.0 ★핵심 시사점 — bump test 걱정 완화★
|
||||
PITOPS의 셀링포인트가 우리 §12.4 난제와 동일: *"정상운전·폐루프 데이터에서 step test 없이 모델 식별 — 진동/불안정/stiction/고잡음/미측정외란 데이터에서도 전처리 없이."*
|
||||
- 우리만의 추가 행운: 플랜트가 **100% MANUAL** → 오퍼레이터 OP 계단 조작 = **개루프 step 데이터가 몇 달치 흩어진 셈** → PITOPS가 어렵게 푸는 폐루프보다 **오히려 식별이 쉬움**.
|
||||
- 남은 과제 = "오퍼레이터 move vs 외란" 분리 → PITOPS의 **미측정 외란 패턴 식별** 기능이 담당.
|
||||
- **결론**: 기존 MANUAL 데이터만으로 모델 식별 우선 시도, bump test는 보조 (§12.4·12.7·12.8 갱신 반영).
|
||||
|
||||
### 13.1 기둥 A — 루프 헬스 모니터링 (APROMON 류) · 우선순위 **즉시**
|
||||
dump만 있으면 바로 구현 가능. 부수효과로 **파일럿 루프를 데이터로 객관 선정**(§9 미해결 자동 해결).
|
||||
- 루프별 KPI: **진동지수(oscillation index)**, **time-in-manual %**, SP추적오차, **IAE**, **밸브 이동률(OP travel)**, **SNR**
|
||||
- 30+ 기준 → 단일 **Grade(0~100)**
|
||||
- **valve stiction 감지**, **frozen/noisy 센서 감지**
|
||||
- 임계 이하 루프 자동 플래그 → 우선순위 작업목록 (수작업 트렌드 감사 대체)
|
||||
- ※ 우리 §5 "hunting 자동 감지기"의 상위 일반화
|
||||
|
||||
### 13.2 기둥 B — 시스템 식별 엔진 (PITOPS 류) · 우선순위 **중** (MPC 전제)
|
||||
- MANUAL/operator 데이터 → **FOPDT·2차·적분형·deadtime·개루프불안정** transfer function 피팅
|
||||
- **시간영역**(Z변환 불요) **제약 비선형 최적화**
|
||||
- **미측정 외란 패턴 식별·분리**(트렌드로 표시)
|
||||
- **stiction/hysteresis 동시 식별**
|
||||
- SISO/MISO 다변수, 초~분 다중 스케일
|
||||
- = §12의 MPC 모델 엔진 + 상층 정상상태 맵의 동역학 보강
|
||||
|
||||
### 13.3 기둥 C — 다목적 제어 설계 (PITOPS 류) · 우선순위 **중**
|
||||
- 목표함수에 **외란(펄스/스텝/램프/사인)·노이즈·OP rate·valve stiction·비선형 게인** 반영
|
||||
- = 우리 hunting-averse 제어의 데드밴드/dwell/rate-limit를 **정량 목표**로 일반화
|
||||
- what-if 시뮬(추정 파라미터로 시나리오 비교)
|
||||
|
||||
### 13.4 부가 규약
|
||||
- **검증 지표**: FIT(%)·IAE·NRMSE
|
||||
- **TTSS(Time to Steady State)** 도구 → 우리 §9 "정상상태 세그먼테이션"과 직결
|
||||
- **데이터 규약**: CV/MV/DV(=PV/OP/외란) 컬럼 구조 → 학습 파이프라인 입력 포맷 채택
|
||||
- **보안 모델**: 식별/학습은 **DB dump 오프라인**으로 (레벨3 네트워크 미접속, COLUMBO 철학)
|
||||
- 데이터 규모: 단일 케이스 ~10만 행급 처리
|
||||
|
||||
### 13.5 우리 플랜과의 연결
|
||||
1. **기둥 A부터** (dump 직후 즉시) → 파일럿 루프 객관 선정 + hunting/stiction 진단
|
||||
2. **기둥 B** = §12 MPC/정상상태 모델 엔진의 청사진
|
||||
3. **기둥 C** = §5 hunting 방지 가드의 정량화
|
||||
|
||||
### 13.6 참고 링크
|
||||
- PiControl 제품 페이지 원문: `docs/PITOPS-브레인스토밍-웹페이지복사.md`
|
||||
- COLUMBO(MPC 유지보수): 정상운전 데이터로 MPC 모델 refit, 오프라인 Excel
|
||||
|
||||
---
|
||||
|
||||
## 14. 데이터 반입 절차 (SOP) — 현장 DB → 우리 환경
|
||||
|
||||
> **전제: 현장에서는 점검·선별이 불가.** 그래서 현장은 **DB 전체를 한 줄로 dump해 파일만 전달**하고,
|
||||
> 테이블 구조 파악·TimescaleDB 처리·복원·최적화는 **전부 우리 쪽에서 파일로부터** 한다.
|
||||
> 결론: **별도 DB `field_hist`** 로 적재(별도 스키마 아님 — `-Fc`가 원본 스키마명을 박아 충돌/rename
|
||||
> 문제 → 별도 DB가 충돌0·복원1방). 라이브 `hc900`(DB `iiot_platform`) 안 건드림.
|
||||
|
||||
### 14.0 우리 환경 (확정)
|
||||
- PG16 + TimescaleDB 컨테이너 `iiot-timescaledb` (`timescale/timescaledb-ha:pg16`), 5432.
|
||||
- 운영 DB `iiot_platform`, 스키마 `hc900`, user `postgres`.
|
||||
- **pg 클라이언트 도구는 컨테이너 안에만** → 모든 복원은 `docker exec`.
|
||||
|
||||
### 14.1 현장이 할 일 — 딱 한 줄 (전체 DB dump)
|
||||
```bash
|
||||
pg_dump -U <사용자> -d <DB이름> -Fc -Z6 -f field_full.dump
|
||||
```
|
||||
- **DB 전체** 통째. 테이블명·구조 몰라도 됨, 점검 불필요.
|
||||
- `-Fc` 단일 압축파일(USB 운반 쉬움), `-Z6` 압축.
|
||||
- 결과 **`field_full.dump` 파일 하나만 전달**.
|
||||
|
||||
**변형:**
|
||||
- DB 이름 모를 때: `psql -U postgres -l` 로 목록 확인 후 위 명령에 사용.
|
||||
- 명령줄 불가, pgAdmin(GUI)만: 대상 DB 우클릭 → **Backup...** → Format **Custom** → 저장 (= `-Fc`).
|
||||
- 같이 알려주면 좋은 것(몰라도 진행 가능): 파일 **대략 용량**(수백MB/수GB/수십GB) → 복원 병렬도·디스크 준비용.
|
||||
|
||||
### 14.2 우리가 할 일 ① — 파일에서 점검 (현장 점검 대체)
|
||||
```bash
|
||||
pg_restore -l field_full.dump | head -50 # PG버전·스키마·테이블·인덱스 목록
|
||||
```
|
||||
→ 이력 테이블명·컬럼(PV/SP/OP/MODE/외란)·timestamp 컬럼·TimescaleDB 여부를 **파일에서 직접 파악**.
|
||||
|
||||
### 14.3 우리가 할 일 ② — 별도 DB로 복원
|
||||
```bash
|
||||
docker cp field_full.dump iiot-timescaledb:/tmp/
|
||||
docker exec -i iiot-timescaledb psql -U postgres -c "CREATE DATABASE field_hist;"
|
||||
docker exec -i iiot-timescaledb pg_restore -U postgres -d field_hist \
|
||||
-j 4 --no-owner --no-privileges -v /tmp/field_full.dump
|
||||
```
|
||||
|
||||
### 14.4 우리가 할 일 ③ — TimescaleDB 최적화 (이력 테이블 확인 후)
|
||||
```sql
|
||||
-- field_hist DB. <history_table>·<ts>·<tagname>은 14.2에서 파악한 실제값
|
||||
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||
SELECT create_hypertable('<schema>.<history_table>', '<ts>', migrate_data => true);
|
||||
ALTER TABLE <schema>.<history_table>
|
||||
SET (timescaledb.compress, timescaledb.compress_segmentby = '<tagname>');
|
||||
SELECT add_compression_policy('<schema>.<history_table>', INTERVAL '7 days');
|
||||
CREATE INDEX ON <schema>.<history_table> ('<tagname>', '<ts>' DESC);
|
||||
```
|
||||
|
||||
### 14.5 주의
|
||||
- **버전 호환**: PG16 `pg_restore`가 구버전 `-Fc` dump 복원 가능(forward 호환).
|
||||
- **현장이 TimescaleDB인 경우**: 전체 `-Fc` dump에 하이퍼테이블 청크가 포함됨 → 복원 시 일부 오류 가능. 그때 우리 쪽에서 `SELECT timescaledb_pre_restore();` → 복원 → `timescaledb_post_restore();` 순서로 재처리. **현장은 신경 안 써도 됨**(우리가 14.2에서 감지해 처리).
|
||||
- **복원 튜닝**: 대용량 시 `SET maintenance_work_mem='1GB'; SET synchronous_commit=off;` + `-j` 병렬.
|
||||
- **분석 접속**: `field_hist` 전용 연결문자열만 추가 (`hc900`과 조인 불필요).
|
||||
|
||||
### 14.6 파일 도착 후 즉시 (요약)
|
||||
1. `pg_restore -l` 로 구조 파악 → 2. `field_hist` 복원 → 3. 하이퍼테이블+압축 → 4. §13.1 기둥 A KPI 쿼리 착수.
|
||||
|
||||
---
|
||||
|
||||
## 15. 실데이터 구조 — shinam dump (2026-06-05 반입·복원 완료)
|
||||
|
||||
> 파일 `docs/PG-Dump-20260605/field_full_shinam.dump` (453MB, PG9.5.25 커스텀 dump) →
|
||||
> **별도 DB `field_hist`** 로 복원 완료(iiot_platform 안 건드림, 무해 경고 1개: public 스키마 중복).
|
||||
> 현장 = **신암정유(주)**, 소스 DB명 `shinam`.
|
||||
|
||||
### 15.1 기간·해상도 (확정)
|
||||
- 기간: **2026-02-05 12:53 ~ 2026-06-05 11:22** (약 120일 ≈ 4개월).
|
||||
- 간격: **주로 30초** (2월 일부 60초 혼재, 간헐적 공백=재기동/다운타임). `dtat` 유니크.
|
||||
- cont001 행수 336,519 (테이블별 20만~33.6만).
|
||||
|
||||
### 15.2 데이터 모델 — WIDE 포맷 + 디코드 테이블
|
||||
- **`cont001`~`cont017`** (17개): 시계열 본체. 컬럼 = `dtat`(timestamp) + `col01..colNN`(real). 설명 "신암정유(주) 아날로그 모니터링 포인트 (Read Only)".
|
||||
- **`ptlist`** (799행): 포인트 정의. `pid, ptname(예 /ASSETS/P10/TI-10117.PV), shortptname, asset, cont`.
|
||||
- **`tblist`** (17행): 테이블 정의. `tid, tblname(cont0NN)`.
|
||||
- **`mapping`** (817행): `tid, pid, oit` — **어느 테이블(tid)의 어느 컬럼(oit)이 어느 포인트(pid)인지** 디코드.
|
||||
- `cont`·`batch`·`batchlist`: 0행(비어있음).
|
||||
|
||||
### 15.3 ★태그 시계열 복원 규칙 (검증 완료)★
|
||||
```
|
||||
ptname → ptlist.pid
|
||||
→ mapping(pid) → (tid, oit)
|
||||
→ tblist(tid) → tblname
|
||||
→ SELECT dtat, col{oit:2자리0패딩} FROM {tblname} ORDER BY dtat
|
||||
```
|
||||
- **oit는 colNN에 직결** (oit=3 → col03). 검증: `TI-10117.PV` → cont015.col03 → ~22°C(온도 합리값).
|
||||
- 한 루프(PV/SP/OP)는 **서로 다른 cont 테이블·컬럼에 흩어져 있을 수 있음** → 시간축(dtat)으로 조인.
|
||||
|
||||
### 15.4 접미사 분포 — OP/SP/PV 확인 (make-or-break 통과)
|
||||
| 접미사 | 개수 | 비고 |
|
||||
|---|---|---|
|
||||
| **PV** | 456 | 공정값 |
|
||||
| **SP** | 114 | 설정값 (신뢰도 ~80%, §4) |
|
||||
| **OP** | 106 | ✅ **제어출력 존재** |
|
||||
| FIELDVALUE | 55 | |
|
||||
| QV | 32 | 적산/유량 추정 |
|
||||
| VALUE | 29 | |
|
||||
| LSET | 6 | local setpoint? |
|
||||
| HZSET | 1 | |
|
||||
|
||||
- **PV+SP+OP 완비 루프 = 104개** (PV+OP만 = 106). → imitation/학습 대상 루프 ~104개 확보.
|
||||
|
||||
### 15.5 진공(PID) 루프 제외 규칙 (확정)
|
||||
- **진공 = 압력제어기 `PICA-*`** (OP 보유 12개) → AUTO/PID 루프이므로 **학습 대상에서 제외**.
|
||||
- 제외 12: PICA-111, -2111, -2121, -3203, -5111, -6111, -6211, -8111A, -9111A, -9211A, -10111A, -10211A
|
||||
- `PI-*`(43개)는 전부 PV만 = 단순 압력지시계 → 어차피 제어루프 아님(무관).
|
||||
- 제외 SQL: `WHERE upper(split_part(base,'-',1)) <> 'PICA'`.
|
||||
- (MODE 태그는 없음 — 100% MANUAL이라 미기록. 위 PICA 제외로 AUTO/MANUAL 구분 대체.)
|
||||
|
||||
### 15.6 학습 대상 루프 인벤토리 (PV+SP+OP 완비, PICA 제외 = **91개**)
|
||||
| 계기 | 루프수 | 종류 | 파일럿 적합성 |
|
||||
|---|---|---|---|
|
||||
| **TICA** | 12 | 온도제어 | ★ **느림 → 30초 적합, 첫 파일럿 이상적** |
|
||||
| **LICA** | 20 | 레벨제어 | 비교적 느림 (차순위) |
|
||||
| LIC/LISA | 3 | 레벨 | |
|
||||
| **FICQ** | 54 | 유량(적산) — 다수. ※사용자 feed-ramp의 FICQ 계열 | 빠름 → 나중 |
|
||||
| FICA | 1 | 유량제어 | 빠름 |
|
||||
| AIC | 1 | 분석기 제어 | |
|
||||
- 전체 완비루프 103 = 진공 12 + 학습대상 91.
|
||||
- **플랜트 인코딩**: 태그 숫자 앞자리 = 차수(1~6,8,9,10 / **7차 없음**), `ptlist.asset`=`/ASSETS/Pn`이 차수 보유. 91개 분포: P6=15, P1=15, P10=14, P9=14, P2=14, P5=7, P8=6, P3=3, P4=3. TICA는 P6·P9·P10에 각 2개.
|
||||
|
||||
### 15.9 플랜트 분류 & 개발 전략 (사용자 도메인 지식)
|
||||
**활성 플랜트 = 1, 2, 5, 6, 8, 9, 10** (3·4차는 **죽은 플랜트, 안 씀** → 제외. 7차 없음.)
|
||||
|
||||
| 플랜트 | 유형 | 비고 |
|
||||
|---|---|---|
|
||||
| **6, 8, 9, 10** | **측류(side-draw) 반도체용 솔벤트** | 동일 유형(형제). 6차용 만들면 8·9·10 확장 |
|
||||
| **1, 2** | 측류 아님 — **2컬럼 경질/중질 제거 일반 증류** | 별도 유형 |
|
||||
| 5 | (유형 미확인, 활성, 7루프) | 확인 필요 |
|
||||
| ~~3, 4~~ | **죽은 플랜트 — 제외** | |
|
||||
|
||||
**개발 순서**:
|
||||
1. **6차 먼저** — HC900 **통신이 살아있음**(현재 live 값은 가공이라도 실 제어경로 shadow→assist→closed 테스트 가능). 6차 = 측류 솔벤트 대표.
|
||||
2. → **8·9·10차** (동일 유형, 코드 재사용)
|
||||
3. → **1·2차** (2컬럼 일반증류, 제어구조 다름)
|
||||
4. 5차는 유형 확인 후.
|
||||
|
||||
**학습 대상 루프(활성, 3·4 제외)**: P6=15, P1=15, P10=14, P9=14, P2=14, P5=7, P8=6 → **계 85개**.
|
||||
|
||||
### 15.10 6차 학습 데이터 출처 — 판정: **실데이터** (2026-06-05 검증)
|
||||
사용자: "6차 플랜트 데이터가 가공". → dump 속 P6를 통계 지문으로 판별한 결과 **실 오퍼레이터 데이터로 강하게 판정**:
|
||||
- **인과성 corr(PV,OP)**: FICQ-6101 0.72, TICA-6111A 0.88 (실 공정 물리 — 가공 노이즈면 ≈0)
|
||||
- **OP 평탄율 98%** + 수천 이산 스텝 + 유지구간 들쭉날쭉(중앙 8분~최대 53h) = 전형적 수동조작 서명
|
||||
- 센서 노이즈(음수 영점 포함)·SP distinct 3~11·반복성 없음 모두 현실적
|
||||
- (LICA-6113은 OP 전구간 0 = 미사용 루프일 뿐)
|
||||
- **해석**: 사용자의 "가공"은 현재 **HC900 live 피드(실시간 테스트 시뮬값)**를 지칭한 것으로 추정. shinam dump의 P6 이력은 실데이터.
|
||||
- **결론**: **6차를 P6 실이력으로 직접 학습** 가능(형제 우회 불필요). 학습·배포 모두 6차 일관.
|
||||
- ✅ **사용자 확정(2026-06-05): "완벽한 현실 데이터, 의심하지 마." → dump P6 = 실데이터 확정.** "가공"은 live 피드 지칭.
|
||||
|
||||
### 15.11 6차 내부 구조 & OP 활동 스크린 (2026-06-05)
|
||||
**6차 = 6-1차(태그 61XX) + 6-2차(62XX), 두 train 독립 운전(연관 없음, 컬럼별 독립).**
|
||||
- 6-1차: TICA-6111A, FICQ-6101/6113/6114/6116/6118, (PICA-6111=진공 제외)
|
||||
- 6-2차: TICA-6211A, FICQ-6201/6213/6214/6216/6218, (PICA-6211=진공 제외)
|
||||
- ★모델링 함의: 한 train 루프의 **부하/외란 피처는 같은 train 안에서만** 사용. 6-1↔6-2 교차 피처 금지. 각 train = 독립 모델링 단위.
|
||||
|
||||
**OP 활동 스크린 (평탄율=OP 정지비율, chg=수동 스텝 수)**:
|
||||
| 루프 | flat% | OP변경 | 판정 |
|
||||
|---|---|---|---|
|
||||
| TICA-6111A | 98.0 | 6590 | ★수동, 풍부 (6-1 파일럿) |
|
||||
| TICA-6211A | 97.7 | 7651 | 수동, 풍부 (6-2) |
|
||||
| FICQ-6101/6113/6201/6118/6213/6218… | 98.4~99.7 | 850~5458 | 모두 수동 서명 |
|
||||
| **LICA-6113/6128/6213** | 100.0 | **0** | **OP=0 사장루프(미사용) → 제외** |
|
||||
|
||||
- AUTO 의심(평탄율 급락) 루프는 전체기준 없음 → 간혹 있다는 AUTO 기간은 **소수 구간** → 학습 시 **세그먼트 단위 AUTO 탐지**(OP 연속변동 구간)로 제거 필요(§4·§13.1 time-in-manual). MODE 태그 없으니 OP 거동으로 판별.
|
||||
- **6차 활성 제어루프**: 6-1차 6개(TICA-6111A + FICQ 5) + 6-2차 6개. LICA 3개는 사장.
|
||||
|
||||
### 15.7 ⚠️ 잔여 주의
|
||||
- "Read Only 모니터링 포인트" 설명 → OP가 오퍼레이터 실제 조작값인지 1차 확인 권장(맞을 것으로 판단).
|
||||
|
||||
---
|
||||
|
||||
## 16. 6-1차 파일럿 (TICA-6111A) — 착수
|
||||
|
||||
### 16.1 6-1차 컬럼 C-6111 — 권위 토폴로지 (기존 `hc900.ff_column_config`/`ff_stream_config`에서)
|
||||
> ★피처 역할을 추측할 필요 없음★ — 팀이 이미 정의해둠. column_id=1, name=**C-6111**, enabled=t, **advisory_only=t**(이미 자문모드 가동).
|
||||
|
||||
| 역할 | 태그 | 출처 컬럼 |
|
||||
|---|---|---|
|
||||
| **제어출력 OP(학습대상)** | **TICA-6111A.OP** = 리보일러 **스팀 밸브(조작)** | steam_op_tag |
|
||||
| **스팀 유량(측정)** | **FIQ-6115** (cont008 PV37/QV38) = 실제 스팀유량/열입력 | 사용자 도메인 |
|
||||
| **제어온도(target)** | 민감단 **TI-6111C** | sensitive_tray_tag |
|
||||
| 온도 프로파일 | TICA-6111a, TI-6111b, TI-6111c, TI-6111d | temp_tags |
|
||||
| **피드(주 외란)** | **FICQ-6101** | feed_tag |
|
||||
| 진공압력 | **PICA-6111** | pressure_tag |
|
||||
| 컬럼 차압(플러딩) | **PI-6111B** | delta_p_tag |
|
||||
| 온도 상한 | 89.5 / p_ref 50 | temp_high_limit |
|
||||
|
||||
**스트림 역할 (ff_stream_config, column_id=1)**:
|
||||
| key | 태그 | role | 비고 |
|
||||
|---|---|---|---|
|
||||
| **P(★측류 제품)** | FICQ-6118 | Commanded | **반도체 솔벤트 제품 = 진짜 측류(side-draw)**. coeff 0.95, θ60/60, τ900 |
|
||||
| R(리플럭스) | FICQ-6113 | Commanded | is_reflux, reflux_from_product |
|
||||
| D(경질분 제거) | FICQ-6114 | LevelDriven | **측류 아님** — 리플럭스 라인의 **경비물(경질분/light) 제거** 유량. level=lica-6113, grade B |
|
||||
| B(중질분 제거) | FICQ-6116 | LevelDriven | **보텀 아님** — **중비물(중질분/heavy) 제거** 유량. level=li-6111, grade B |
|
||||
|
||||
> **컬럼 본질**: C-6111 = **측류 정제 컬럼**. 측류 제품 P(FICQ-6118, 반도체 솔벤트)를 가운데서 뽑고, 경질분(D)·중질분(B)을 양쪽으로 퍼지, 리플럭스(R) 환류. 오퍼레이터는 스팀(TICA-6111A.OP)으로 온도 프로파일(민감단 TI-6111C)을 잡아 이 분리·순도를 관리.
|
||||
|
||||
> ★운전 현실(사용자)★: **D(FICQ-6114)·B(FICQ-6116)은 유량이 작아 제어가 어려워 대부분 고정 운전** (스크린 평탄율 99.6~99.7%로 일치). → 모델에서 **준상수**로 취급(능동 조작변수 아님). **오퍼레이터 핵심 레버 = 스팀(TICA-6111A.OP/FIQ-6115) + 리플럭스(FICQ-6113) + 측류제품(FICQ-6118)**. 피드 FICQ-6101은 주 외란.
|
||||
|
||||
**센서 물리 위치 (사용자 도메인) — C-6111 수직 프로파일 하부→상부**:
|
||||
| 태그 | 위치 | 모델 의미 |
|
||||
|---|---|---|
|
||||
| **TICA-6111A** | 리보일러 온도(최하부, 최고온) | 스팀 직접 제어점(OP 연결) |
|
||||
| **TI-6111B** | 중간부, **원료투입 디스트리뷰터 위** | 피드존 온도 |
|
||||
| **TI-6111C** | 중상부, 팩킹 넘어 **제품 추출 트레이 근처** | ★**민감단=측류제품(FICQ-6118) 순도 직결**(sensitive_tray) |
|
||||
| **TI-6111D** | 상부 리플럭스 디스트리뷰터 밑(최저온) | 탑상 온도 |
|
||||
| TI-6103 | **원료 예열 온도** | 주 외란(피드 엔탈피) |
|
||||
| TI-6117 | 제품 탱크 이송 전 온도 | 다운스트림(제품 상태 — 제어입력 아님, 품질 프록시 가능) |
|
||||
| LI-6111 | **리보일러 레벨** | 하부 인벤토리/하이드롤릭 |
|
||||
| LICA-6113 | **리플럭스 드럼 레벨** | 환류 인벤토리 |
|
||||
|
||||
> **온도 순서 A > B > C > D** (리보일러 최고온 → 탑상 최저온, 단조 감소 구배). 정상 분리 = 이 구배 유지, 구배 붕괴=분리 이상 신호.
|
||||
> ΔT(인접단 차, 예: A−B, B−C, C−D)가 **분리도 지표** → 피처로 사용. 모델: `스팀 = f(목표 TI-6111C, 피드 FICQ-6101, 원료예열 TI-6103, 리플럭스 FICQ-6113, 측류 FICQ-6118, 진공 PICA-6111)`.
|
||||
|
||||
- **제어 철학**: 오퍼레이터가 **스팀(TICA-6111A.OP)** 으로 민감단 온도(TI-6111C)를 잡고, **피드 FICQ-6101**·진공·스트림이 외란. → 모델 `OP_ss = f(목표온도, FICQ-6101, PICA-6111, PI-6111B, 스트림유량…)`.
|
||||
- **저장탱크 온도 제외**(사용자): TI-6117, TI-6121/6122/6123/6125/6126.
|
||||
- ★기존 ff 시스템(FeedforwardSupervisor 등)이 C-6111에서 이미 advisory로 가동 중 → 우리 학습형 맵은 **이와 연계/보완**(중복 금지). FROM-TO·역할은 `ff_*`·`pid_equipment.from_tag/to_tag`에서 읽음.
|
||||
|
||||
### 16.2 파일럿 적합성 (TICA-6111A 실측)
|
||||
- OP 가동률 **99.4%** (거의 항상 활성) · 정상상태 **92.7%** (정상상태 맵 학습에 풍부)
|
||||
- **SP 신뢰 67%**(±2 이내) → SP 불완전 확정 → **§4대로 정착 PV를 target으로** 사용이 옳음.
|
||||
- 평균 |SP−PV| 1.51 (오퍼레이터가 PV를 목표 근처로 잘 유지)
|
||||
|
||||
### 16.3 다음 모델링 단계 (오프라인, Python)
|
||||
1. field_hist에서 TICA-6111A + 6-1 피처 풀 pull.
|
||||
2. **★운전상태 분류 (필수 전처리)★** — 각 시점을 다음 모드로 라벨:
|
||||
- **STOPPED**(유량0·실온) / **STARTUP**(승온) / **LINEOUT-전환류**(hot+진공ON **but 측류제품 FICQ-6118≈0**, 리플럭스 높음, 저부하 — 프로파일 정렬·랩샘플 대기) / **LOAD-RAMP**(피드·제품 상승중) / **RUNNING-생산**(피드·제품 정상범위, 정상상태) / **SHUTDOWN**(하강).
|
||||
- 판정 신호: 피드 FICQ-6101, 리보일러온도 TICA-6111A, 진공 PICA-6111, **측류제품 FICQ-6118(≈0=전환류 핵심 판별)**, 리플럭스/제품 비.
|
||||
- **정상상태 맵 학습 = RUNNING-생산 구간만** (여러 로드율의 정상점은 **모두 유효** → 운전점 커버리지↑). **LINEOUT·LOAD-RAMP·STARTUP·SHUTDOWN·STOPPED 전부 제외**.
|
||||
- ★STARTUP/SHUTDOWN/LOAD-RAMP/LINEOUT 과도는 **버리지 말고 1급 데이터로 보존·라벨**★ — (a)동특성 식별 + (b)**START-UP/SHUTDOWN 절차학습(②③)의 학습데이터**(§16.4).
|
||||
- ※ 기존 `hc900.v_plant_running_state`(+_corroborated, vacuum_torr/pump 기반) 판정 로직을 field_hist에 재현.
|
||||
- 💡 향후: 라인아웃→제품컷 시점 학습 시 **START-UP 어드바이저**(언제 로드 올릴지)로 확장 가능 — 현 스코프 밖, 메모.
|
||||
4. AUTO 구간 제거(OP 연속변동 탐지), off-spec 제외, 정상상태 세그먼트(dPV≈0·dOP=0) 추출.
|
||||
5. **`OP_ss = f(정착PV, 6-1 부하)`** 회귀(설명가능 모델: 선형/GBM/GMR) + 피처 중요도·잔차 분석.
|
||||
- ★스팀유량 FIQ-6115 활용 두 갈래★:
|
||||
- (밸브특성) OP(TICA-6111A.OP) ↔ FIQ-6115 관계 학습 → **밸브 stiction/비선형 진단**(§5).
|
||||
- (에너지모델) target = **스팀유량 demand** `FIQ-6115_ss = f(목표온도, 피드 FICQ-6101, 진공, 스트림)` → 밸브문제와 분리, 더 강건. 이후 유량→밸브% 역변환.
|
||||
6. 산출물: 맵 + FIT/IAE 검증 + "오퍼레이터 OP가 가용변수로 얼마나 설명되나".
|
||||
→ 이후 shadow 모드용 예측기로 연결(§7).
|
||||
|
||||
### 16.4 학습 대상 3종 (스코프 확장 — 사용자)
|
||||
정상상태만 학습하지 않는다. START-UP/SHUTDOWN 절차도 결국 학습(안전 자동화).
|
||||
| # | 대상 | 문제 유형 | 시점 |
|
||||
|---|---|---|---|
|
||||
| ① | 정상 생산 제어 | 정상상태 맵 `OP_ss=f(...)` (회귀) | **현재** |
|
||||
| ② | **START-UP 절차** | **시퀀스/절차 모방** — 시간순 동작 + **전이 트리거**(예: 프로파일 정렬+샘플OK→제품컷→로드램프) | 추후 |
|
||||
| ③ | **SHUTDOWN 절차** | 시퀀스/절차 모방(역순, 안전) | 추후 |
|
||||
- ②③은 ①과 문제 종류가 다름: "조건→OP"가 아니라 **"상태→다음단계 언제·어떻게"**. 궤적/절차 학습.
|
||||
- ★**6모드 상태분류기(§16.3-2)가 ②③의 backbone**: STOPPED→STARTUP→LINEOUT→LOAD-RAMP→RUNNING→SHUTDOWN 전이를 학습. 상태분류 = ① 필터 + ②③ 골격 이중역할.
|
||||
- 안전 중요(반도체 솔벤트 startup/shutdown = 고위험 과도). → 과도구간 데이터 1급 보존 필수.
|
||||
|
||||
### 16.5 진행 로그 (2026-06-05)
|
||||
**착수 완료 — 추출기 + 운전모드 분류 + 타임라인 검증.** 코드: `scripts/analysis/c6111_extract.py`
|
||||
(재사용 추출기 `tag_frame()` = ptlist/mapping/tblist 디코드). 산출: `c6111_data.pkl`, `c6111_timeline.png`, `c6111_episode.png`.
|
||||
- **운전모드 분포**(4개월): PROD 99.2%(2781h), SHUTDOWN 0.4%, LINEOUT 0.2%, STARTUP/STOPPED 각 0.1%.
|
||||
- **여러 로드 캠페인** 확인(3~4월 고부하 ~700-900, 5월초 이후 저부하 ~400) → 맵 운전점 커버리지 양호.
|
||||
- **과도 에피소드 ~4회**(5/13 10h, 4/30 7h, 5/2, 2/15) — 각각 깨끗한 shutdown→냉각→startup 사이클.
|
||||
- ★STARTUP 절차 실데이터 검증: 진공재확립→스팀투입→승온→**전환류(리플럭스 ON, 제품 0)**→정렬→**제품컷인**→생산. 사용자 설명과 일치.
|
||||
- 임계(분포 기반): hot&vacuum = reb_temp>60 & vacuum<200 & steam_op>5; 전환류 = product<80.
|
||||
- ⚠️ 절차학습(②③)은 **few-shot(~4 에피소드)** — 6-2·8·9·10 동일유형 에피소드 합치면 샘플 보강 가능.
|
||||
- 다음: ① **생산 정상상태 맵** (PROD 구간, 밸브특성 OP↔FIQ-6115 + `스팀=f(목표온도,피드,…)`).
|
||||
|
||||
### 16.6 ① 생산 맵 결과 (2026-06-05) — 코드 `scripts/analysis/c6111_prodmap.py`
|
||||
- **밸브특성**: 스팀유량 ≈ 18.5·OP − 220 (OP 22~48%→flow 98~698). **히스테리시스 0.4% = stiction 사실상 없음** → 이 컬럼 hunting은 스팀밸브 원인 아님.
|
||||
- **★학습 단위 = 운전점(6h 중앙값)★**: 정상상태가 98%로 너무 안정 → 점단위 회귀는 캠페인내 변동 부족으로 실패(음의 R²). **6h 운전점(479개)으로 집계하면 해결**. steam vs feed에 **이산 로드 캠페인 클러스터**(feed≈400/500/700/800-900) 확인.
|
||||
- **맵 `스팀유량 = f(피드, 제품, 목표 T_C)`**: GBM **test R²=0.993**, MAE 7.9(스팬 1.8%). Linear 0.987. **피드 단독 R²=0.985, steam/feed비=0.729**.
|
||||
- 피처 중요도: **T_C 0.42 > feed 0.31 > product 0.26**, 나머지(예열·T_D·진공) ~0. feed_preheat 계수 음(−)=물리 정확.
|
||||
- **함의**: 오퍼레이터 스팀 **99% 설명/학습 가능**. 본질은 **steam/feed 비율(~0.73) + 온도·제품 보정**. = 기존 `ff_column_config` steam/feed 계수를 **데이터로 검증·정밀화**(경로 나).
|
||||
- ⚠️ 이건 **정상상태(에너지밸런스) 맵** = §4 OP_ss 베이스라인(전향). 아직 동특성·캠페인내 미세 온도제어(피드백 트림)는 별도.
|
||||
- 다음 후보: (a) shadow-mode 예측기로 연결(live 피드/제품/목표→스팀 권장, 오퍼레이터 비교) / (b) 캠페인내 미세 온도 피드백(트림) 분석 / (c) 6-2·8·9·10 확장.
|
||||
|
||||
### 16.7 Shadow 예측기 백테스트 (2026-06-05) — 코드 `scripts/analysis/c6111_shadow.py`
|
||||
시간 전진 분할(학습 2~4월 70% / shadow held-out 5월 30%), 스팀유량 예측 →밸브 역특성→ 예측 OP를 **실제 오퍼레이터 OP와 비교**. OOD(학습 운전envelope 1~99% 밖) 게이트 포함.
|
||||
- **신뢰구간(in-envelope): OP MAE 0.80%, |예측−실제 OP|≤2% 가 94%** → **오퍼레이터 손을 충실히 모방**.
|
||||
- **held-out 5월 = 100% OOD** (저부하 feed 378~423 < 학습하한 433, 목표 T_C도 85.3→84.0으로 낮춤). 예측 OP가 **체계적 +4.0%(std 1.1%) 과예측** = 외삽 바이어스.
|
||||
- ★**OOD 게이트가 5월 전체를 정확히 플래그 → "오퍼레이터 폴백"** = §5 안전설계(범위밖→사람) 실증. shadow가 **나쁜 조언을 내기 전에 스스로 기권**.
|
||||
- **교훈**: 모델은 신뢰구간에서 탁월(94% 일치)하나, **새 운전레짐엔 외삽 바이어스** → 반드시 (1)OOD 게이트 + 오퍼레이터 폴백, (2)연속/롤링 재학습으로 envelope 확장.
|
||||
- 다음 후보: (1) **롤링 재학습** 데모(5월이 in-envelope가 됨을 확인) / (2) **operator-assist**(예측 OP 화면표시) / (3) 캠페인내 미세 온도 트림 / (4) 6-2·8·9·10 확장.
|
||||
|
||||
### 16.8 롤링 재학습 (2026-06-05) — 코드 `scripts/analysis/c6111_rolling.py`
|
||||
walk-forward: 5월을 하루씩 전진, '그 날 이전 전체 이력(expanding)'으로 매일 재학습→그 날 예측. 입력 평활 인과(trailing).
|
||||
| 모델 | 5월 OP MAE | ≤2% 일치 |
|
||||
|---|---|---|
|
||||
| 정적(2~4월 고정) | 3.88% | 3.7% |
|
||||
| **롤링 재학습** | **1.17%** | **83.7%** |
|
||||
- **적응 곡선**: 05-01 첫날 4%(OOD 100%) → **05-02 단 하루 흡수로 1.6%** → 이후 ~0.5%. **OOD 첫주 35%→0%**.
|
||||
- 05-14~19 일시 상승(5/13 shutdown/startup 직후 sub-레짐) 후 자동 회복.
|
||||
- ★**완성된 안전 설계**: 롤링 재학습(envelope가 플랜트 추종) + OOD 게이트(전이 첫 1~2일은 오퍼레이터 폴백) → 적응 후 94% 모방. 새 레짐 진입 시 사람이 잠깐 몰다 모델이 흡수.
|
||||
- **결론**: ① 생산 제어의 offline 파이프라인(추출→모드분류→정상맵 R²0.99→shadow 94%→롤링 적응) **완주·검증**. 핵심 = `스팀≈0.73·피드 + 온도/제품 보정`, 롤링+OOD로 안전.
|
||||
- 다음 후보: (2) operator-assist 패키징(예측 OP 화면) / (3) 캠페인내 미세 온도 트림(피드백) / (4) 6-2·8·9·10 확장 / (5) live C# shadow 포팅.
|
||||
|
||||
### 16.9 캠페인내 온도 트림 = gentle 피드백 (2026-06-05) — 코드 `scripts/analysis/c6111_trim.py`
|
||||
부하 일정 구간(PROD의 70%)에서 오퍼레이터의 스팀 미세조정이 무엇에 반응하나 분석.
|
||||
- **온도 유지 성능**: 목표대비 **reb-A·T_C 오차 std ≈ 0.07~0.09℃ (±0.13℃, 노이즈 수준)**. 극도로 안정.
|
||||
- **OP는 1.3%만 이동**(98.7% 정지), dwell 중앙 21분.
|
||||
- **트림 트리거 약함**: 작은 이동 dOP vs reb-A오차 corr **−0.245**(T_C −0.10보다 큼; TICA 직접변수). **데드밴드 차이 없음**(이동/비이동 |오차| 동일). 큰 이동(11회)은 **피드변화(−0.65)=전향**.
|
||||
- ★**결론: 캠페인 내부에선 컬럼이 자기제어(self-regulating), 스팀 거의 개루프. 의미있는 피드백 트림이 거의 없음.** 제어는 **전향 맵(§16.6)이 지배**, 피드백은 미미(저게인 ~−0.6%OP/℃, 데드밴드 ~0.1℃=노이즈, dwell ~20분).
|
||||
- **함의**:
|
||||
- **전향 맵(steam=0.73·피드+보정)이 사실상 정상생산 제어 전체.** §5의 "전향+gentle 데드밴드 트림" 중 전향이 압도적.
|
||||
- **hunting 저위험 설명**: 오퍼레이터가 공정과 싸우지 않음(스팀=부하, 온도 자기안정).
|
||||
- **자동화 가치 = 부하변동 시 일관된 전향 스팀 + 전이/startup 처리**, 정상 유지(쉬움)가 아님.
|
||||
- 데이터 도출 gentle 제어 스펙: FF `steam=f(피드,제품,목표T_C)` + (옵션)경량 FB(reb-A오차, gain~−0.6, 데드밴드±0.1℃, dwell≥20분).
|
||||
|
||||
### 16.10 ② START-UP 절차 (2026-06-05, few-shot) — 코드 `scripts/analysis/c6111_startup.py`
|
||||
전체 모드 데이터에서 **제품 컷인** 이벤트 탐지→정렬→중첩, 절차를 해석가능 레시피로 추출.
|
||||
- 탐지 5건 중 **깨끗한 절차적 컷인 3건**(02-15, 04-30 22:00, 05-13; 컬럼 정렬 T_C~83-84℃, ΔT~2℃), 2건은 미정렬 블립(T_C 63-66, ΔT 30-50 — 비정상/부분).
|
||||
- **★START-UP 레시피★** (시퀀스): STOPPED → 진공재확립 → **스팀투입·승온** → **전환류 라인아웃**(리플럭스 ON, 제품 0) → **제품 컷인** → **피드 램프** → 생산.
|
||||
- **단계 타이밍**: 스팀투입→컷인(라인아웃 길이) **60~120분 가변**, 리플럭스확립→컷인 25~82분, 컷인→풀로드 빠름(0.5~18분).
|
||||
- ★**제품 컷인 트리거 = 조건기반(시간 아님)**, 3건 일관: **reb-A 84.6±0.5℃, ΔT(A-D) 1.9±0.4℃** (프로파일이 생산상태로 압축=정렬 완료). 사용자 설명("정렬 상태 보고 로드 올림")과 정확히 일치.
|
||||
- **→ START-UP 어드바이저 플레이북**: 승온 후 **reb-A≈84.5℃ & ΔT(A-D)≈2℃ 도달 시 제품 컷인** 권장, 이후 피드 램프. = 안전·설명가능 절차 자동화의 핵심 게이트.
|
||||
- ⚠️ **few-shot(3 클린/4개월)** → 형제(6-2·8·9·10) startup 합치면 트리거 신뢰도·변동성 보강.
|
||||
- 다음: 6-2·8·9·10 확장(샘플 보강+코드 재사용) / SHUTDOWN 절차(③) / operator-assist·live 포팅.
|
||||
|
||||
## 17. 온도 프로파일 불변성 = 데이터 신뢰도·품질 게이트 (2026-06-05, 사용자 통찰)
|
||||
|
||||
**원리**: 같은 제품(예 PGMEA)은 물성이 부하와 무관 → **진공이 일정한 한, 컬럼 온도분포(A>B>C>D)는 저유량·고유량에서 동일**해야 함(온도=조성, 진공 고정 시). 따라서 **부하에 따라 프로파일이 변하면 = 조성/순도 드리프트 or 데이터 이상** → 신뢰도·품질 게이트.
|
||||
|
||||
**검증 (부하 5분위별 온도, 진공 일정):**
|
||||
- **9-1: load-invariant ✅** — 피드 253→1146(5×)에도 A 84.1·T_B 83.9·T_C 82.2·T_D 81.5 거의 불변(변동 A0.87/T_C0.27/T_D0.13℃). = 전부하 일관 생산, 신뢰 高.
|
||||
- **6-2: 드리프트 ⚠️** — 피드 394→897에 **A 85.2→90.4(+5.2℃)**, T_C 84.1→86.7(+2.7), T_D~83 불변, **진공 113 일정(변동0.04)**. → 압력 아닌 **조성/순도 저하**(중질분 상승, 프로파일 늘어남) = 고부하 품질 드리프트.
|
||||
|
||||
**함의:**
|
||||
- **OOD보다 강력한 게이트** — "본 적 있나"가 아니라 **"이 제품의 올바른 운전상태인가"를 물리로 판정**.
|
||||
|
||||
**★정정 (2026-06-05, 사용자 chemistry): 6-2는 품질드리프트가 아니라 제품 전환★**
|
||||
- 6-2 reb_temp 히스토그램이 **bimodal(이봉)**: ≤88.5°C 무리 + 90~91°C 무리, 사이 88.5~90°C는 **거의 빔(갭)**. 주별로도 저온(85~87°C, 정상부하)·고온(90.6°C, 4/20~5/24 고부하)이 각각 내부 안정. max 91.2°C.
|
||||
- 사용자: **PGMEA는 90°C 미만(50 TORR)에서 생산** → **≤88°C=PGMEA, 90~91°C=다른 제품**. 즉 **품질 저하 아니라 제품 전환 캠페인**.
|
||||
- → **온도프로파일 게이트는 제품별로 적용**(먼저 제품 식별 → 그 제품 기준 일관성). **스팀모델도 제품별로** 분리 — 6-2 "고부하 외삽 실패"의 진짜 원인은 **두 제품을 한 모델에 섞은 것**.
|
||||
- ⚠️ 단위 확인 필요: 진공이 113으로 읽힘(50 TORR 기준과 다름) — PICA 단위 or 6-2 실제 진공 확인. (이봉 구조는 단위 무관 유효)
|
||||
- (9차는 단일제품 추정=프로파일 불변, 신뢰高.)
|
||||
|
||||
### 17.1 ★제품별 분리가 선결 — 생산품종 PMA/PGMEA/EL★
|
||||
플랜트는 여러 솔벤트 생산: **PM/PGME(~120°C) < PMA/PGMEA(~146°C) < EL(~154°C)** (상압 비점). 같은 진공에서 **비점 높을수록 컬럼 온도 높음** → **온도 프로파일 = 제품 식별자**. 6-2 이봉(≤88°C vs 90~91°C)=서로 다른 솔벤트.
|
||||
**방법론 수정 (전체 적용)**:
|
||||
1. **온도 프로파일로 데이터를 제품(운전모드)별 클러스터링 → 라벨링** (전처리 선결단계).
|
||||
2. **제품별로** 스팀맵·신뢰도게이트·startup 레시피 각각 학습. (전 구간 단일모델 금지)
|
||||
3. 기존 "고부하 외삽 실패/온도 드리프트"의 상당 부분은 **제품 혼합 아티팩트** — 분리 시 각 제품은 9차처럼 깨끗할 것.
|
||||
4. → §16.3 운전모드 분류에 **제품 차원 추가**(STOPPED/STARTUP/…뿐 아니라 제품A/B/C).
|
||||
|
||||
### 17.2 SP-PV 괴리 = "SP 방치(MANUAL 캠페인)" 탐지 (≠세척) (2026-06-05)
|
||||
정상 생산이면 오퍼레이터가 SP를 온도 근처 유지 → **|PV−SP| 작음**. MANUAL로 다른 제품/특수 캠페인을 돌리며 SP를 안 맞추면 **|PV−SP| 큼**.
|
||||
**검증 (TICA SP-PV 괴리):**
|
||||
| | 6-1 | 6-2 |
|
||||
|---|---|---|
|
||||
| \|PV−SP\| p99 | **3.8°C** | **27.5°C** |
|
||||
| \|PV−SP\|>5°C | **0.0%** | **24.9%** (5/04~6/07) |
|
||||
|
||||
**★중요 정정 (2회): 6-2 고괴리 구간은 세척 아님 = 다른 제품★**
|
||||
- 고괴리(|PV-SP|>5) 구간 실측: **feed 888·제품 809 (풀 생산 수준!) + reb 90 + reflux 1738(정상 828의 2배)**.
|
||||
- 세척이면 피드가 끊겨야 하는데 **정상 생산량 피드** → 세척 아님. **고온·고리플럭스 = 다른 솔벤트(EL 추정, bp 154>PGMEA 146) 풀 생산 캠페인**, MANUAL로 SP 방치(63.3).
|
||||
- **세척 vs 제품전환 구분 = 피드/제품 수준**: 풀 피드=다른 제품, 피드 끊김=세척. (앞선 "100°C 물끓임" 및 "SP방치=세척" 둘 다 오류 — 이 창엔 명확한 세척 없을 수 있음.)
|
||||
- → **리플럭스 비(828 vs 1738)·온도가 강력한 제품 구분자.** §17.1 제품별 분리 재확인 — 6-2는 최소 2제품(저온 PGMEA + 고온 EL추정).
|
||||
- 탐지기 정리: `|PV-SP|>5°C` = "비표준 SP 운전" 플래그(제품전환·세척 후보) → **피드/제품으로 세부 분류** → §13.1 기둥 A 모니터.
|
||||
|
||||
### 17.3 ★현장 정보(2026-06-05): 품질이슈로 생산조건 변경 중★ — 데이터 선별의 핵심
|
||||
사용자가 현장서 직접 들음: **요즘 품질 이슈로 생산 조건을 이리저리 바꾸는 중.** → 5~6월 6-2 고온·고리플럭스·SP방치 구간의 정체 = **세척도 EL캠페인도 아닌 품질 트러블슈팅(조건 실험)**.
|
||||
- 우리 탐지기 3종(온도프로파일 드리프트·SP-PV 괴리·리플럭스 변화)이 모두 이 불안정 구간을 정확히 플래그 — 도구는 작동, 해석 확정.
|
||||
- ★**핵심 함의: 오퍼레이터가 실험/헤매는 중인 데이터는 모방 금지**(잘된 제어 아님). **학습 = 안정·정착 구간만.** 품질이슈 조건변경 구간 제외.★
|
||||
- 깨끗한 imitation 대상 = **6-1, 9-1, 6-2의 2~4월**. → **신뢰도 게이트(SP-PV 괴리·온도프로파일·리플럭스 안정)를 "학습 데이터 선별기"로 격상**(단순 모니터 아님).
|
||||
- 운전: 플랜트가 조건 흔드는 중 → shadow/자문이 더더욱 옳음(과거 안정운전 레퍼런스). **closed-loop 절대 금물**(조건 실변동 중이라 더 위험).
|
||||
|
||||
**제품화(권장)**: 컬럼별 **기준 온도프로파일**(안정 부하구간) 설정 → 실시간 **프로파일 편차 → 신뢰도/품질 드리프트 경보**. §13.1 기둥 A(루프 헬스 모니터링)에 추가. 학습 무관하게 "지금 제대로 만들고 있나" 판정.
|
||||
|
||||
### 15.8 다음 액션
|
||||
1. (선택) 큰 cont 테이블 TimescaleDB 하이퍼테이블+압축 (§14.5) — 분석 스캔 가속.
|
||||
2. **§13.1 기둥 A**: 91개 루프 KPI(진동지수·IAE·OP travel·stiction) → 파일럿 선정. **TICA 12개부터** 권장.
|
||||
3. §4 정상상태 세그먼테이션 + SP 신뢰도 실측 → `OP_ss=f(정착PV,부하)` 맵.
|
||||
40
docs/현장-데이터베이스서버-덤프방법.md
Normal file
40
docs/현장-데이터베이스서버-덤프방법.md
Normal file
@@ -0,0 +1,40 @@
|
||||
윈도우 환경이고 그 머신이 DB 서버 자체라면, PostgreSQL이 설치돼 있으니 pg_dump.exe가 이미 그 PC에 있습니다. 그게
|
||||
대용량에 가장 안전하고 효율적입니다. EMS GUI는 백업으로 두죠.
|
||||
|
||||
1순위 — 명령 프롬프트에서 pg_dump.exe (대용량에 최적)
|
||||
|
||||
DB 서버라 PostgreSQL bin이 설치돼 있습니다.
|
||||
|
||||
① bin 폴더 확인: 탐색기로 C:\Program Files\PostgreSQL\ 열기 → 버전 숫자 폴더(예: 16, 14) 안 bin에 pg_dump.exe 존재.
|
||||
|
||||
② cmd(명령 프롬프트) 열고 한 줄 (버전·DB이름만 본인 값으로):
|
||||
"C:\Program Files\PostgreSQL\16\bin\pg_dump.exe" -U postgres -d <DB이름> -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에 박아두는
|
||||
게 다음에 도움이 됩니다.)
|
||||
80
scripts/analysis/c6111_export_model.py
Normal file
80
scripts/analysis/c6111_export_model.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
모델 JSON export → C# SteamAdvisor에서 로드.
|
||||
|
||||
선형근사(1안): GBM 대신 LinearRegression 계수 export.
|
||||
steam = w0 + w1*feed + w2*product + w3*T_C
|
||||
valve_inv(flow) = poly3 → OP
|
||||
|
||||
사용법:
|
||||
python3 c6111_export_model.py --data c6111_data.pkl --prefix c6111
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import LinearRegression
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
parser.add_argument("--output", help="JSON 출력 경로 (기본: scripts/analysis/{prefix}_model.json)")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
|
||||
prod = df[df["mode"] == "PROD"].copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)]
|
||||
prod = prod.dropna(subset=FEATURES + ["steam_op", "steam_flow"])
|
||||
ops = (prod.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||
.dropna(subset=["steam_flow", "feed"]))
|
||||
ops = ops[ops["feed"] > 50]
|
||||
|
||||
# 선형 모델
|
||||
lr = LinearRegression()
|
||||
lr.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
r2 = lr.score(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
print(f"선형 steam_flow R² = {r2:.4f} (GBM 대비 비교용)")
|
||||
|
||||
# 밸브 역특성: steam_flow → steam_op (3차)
|
||||
vp = np.polyfit(prod["steam_flow"], prod["steam_op"], 3)
|
||||
|
||||
# Envelope (1%, 99%)
|
||||
lo = ops[FEATURES].quantile(0.01)
|
||||
hi = ops[FEATURES].quantile(0.99)
|
||||
|
||||
# GBM feature importance (참고용)
|
||||
try:
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
gbm = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||
learning_rate=0.05, random_state=0)
|
||||
gbm.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
gbm_r2 = gbm.score(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
except Exception:
|
||||
gbm_r2 = None
|
||||
|
||||
model = {
|
||||
"column": args.prefix,
|
||||
"features": FEATURES,
|
||||
"linear_coeffs": lr.coef_.tolist(),
|
||||
"intercept": lr.intercept_,
|
||||
"linear_r2": round(r2, 4),
|
||||
"gbm_r2": round(gbm_r2, 4) if gbm_r2 else None,
|
||||
"valve_poly": vp.tolist(),
|
||||
"envelope_lo": {c: round(float(lo[c]), 1) for c in FEATURES},
|
||||
"envelope_hi": {c: round(float(hi[c]), 1) for c in FEATURES},
|
||||
"n_operating_points": len(ops),
|
||||
"n_prod_rows": len(prod),
|
||||
}
|
||||
out = args.output or (BASE + f"{args.prefix}_model.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(model, f, indent=2)
|
||||
print(f"\n모델 export: {out}")
|
||||
print(json.dumps(model, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
251
scripts/analysis/c6111_extract.py
Normal file
251
scripts/analysis/c6111_extract.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
컬럼 데이터 추출 + 운전모드 1차 특성 분석.
|
||||
|
||||
field_hist DB(shinam 실데이터, WIDE 포맷)에서 ptlist/mapping/tblist로 태그를 디코드해
|
||||
tidy DataFrame을 만든다. 재사용 가능한 tag_frame() 추출기 포함.
|
||||
|
||||
근거: docs/학습형제어-오퍼레이터모방-플랜.md §15(디코드), §16(C-6111 토폴로지).
|
||||
|
||||
형제 컬럼 확장: roles_for(prefix, asset)로 파라미터화.
|
||||
- 6-1: prefix=61, asset=/ASSETS/P6 (기본)
|
||||
- 6-2: prefix=62, asset=/ASSETS/P6
|
||||
- 8: prefix=81, asset=/ASSETS/P8
|
||||
- 9: prefix=91, asset=/ASSETS/P9 (또는 92)
|
||||
- 10: prefix=101, asset=/ASSETS/P10 (또는 102)
|
||||
"""
|
||||
import sys
|
||||
import psycopg
|
||||
import pandas as pd
|
||||
|
||||
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
|
||||
ASSET = "/ASSETS/P6"
|
||||
|
||||
# --- 형제 컬럼 역할 생성기 ---
|
||||
# DB 검증 결과(2026-06-05) 기반 예외 오버라이드:
|
||||
# P8(81): TICA에 A/B/C/D 접미사 없음, PICA-8111A (with A suffix)
|
||||
# P9(91): PICA-9111A (with A suffix). 92xx 2차 컬럼 존재
|
||||
# P10(101): FICQ-10114A (not 10114), PICA-10111A, LIA-10111 (not LICA). 102xx 2차 컬럼 존재
|
||||
COLUMN_EXCEPTIONS = {
|
||||
"51": {
|
||||
# P5: 민감단 TI-5111C 센서 없음(A/B/D만 존재) → T_C를 TI-5111B로 대체 (사용자 확정 2026-06-05).
|
||||
# startup 트리거는 reb-A·ΔT(A-D) 사용이라 영향 없음.
|
||||
"T_C": "TI-5111B.PV",
|
||||
},
|
||||
"81": {
|
||||
"steam_op": "TICA-8111.OP",
|
||||
"reb_temp": "TICA-8111.PV",
|
||||
"vacuum": "PICA-8111A.PV",
|
||||
},
|
||||
"91": {
|
||||
"vacuum": "PICA-9111A.PV",
|
||||
},
|
||||
"92": {
|
||||
"vacuum": "PICA-9211A.PV",
|
||||
},
|
||||
"101": {
|
||||
"light": "FICQ-10114A.PV",
|
||||
"vacuum": "PICA-10111A.PV",
|
||||
"reb_level": "LIA-10111.PV", # 10-1차 리보일러 레벨 (LI-10111 없음, 사용자 확정)
|
||||
# reflux_drum은 base 규칙 LICA-10113.PV 사용(실존). 기존 LIA-10111 매핑은 오류(=리보일러레벨)였음.
|
||||
},
|
||||
"102": {
|
||||
"light": "FICQ-10214.PV",
|
||||
"vacuum": "PICA-10211A.PV",
|
||||
"reb_level": "LIA-10211.PV", # 10-2차 리보일러 레벨
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def roles_for(prefix, asset=ASSET):
|
||||
"""{role: shorttag} dict 생성. prefix 예: '61', '62', '81', '91', '101'.
|
||||
|
||||
Base 규칙(6-1 기준, docs/작업지시서-학습형제어-다음단계.md 작업1):
|
||||
feed=FICQ-{p}01, reflux=FICQ-{p}13, light(D)=FICQ-{p}14,
|
||||
heavy(B)=FICQ-{p}16, product(P)=FICQ-{p}18,
|
||||
steam_op=TICA-{p}11A.OP, reb_temp=TICA-{p}11A.PV,
|
||||
steam_flow=FIQ-{p}15, T_B=TI-{p}11B, T_C=TI-{p}11C, T_D=TI-{p}11D,
|
||||
vacuum=PICA-{p}11.PV, dp=PI-{p}11B.PV,
|
||||
reb_level=LI-{p}11.PV, reflux_drum=LICA-{p}13.PV,
|
||||
feed_preheat=TI-{p}03.PV
|
||||
|
||||
COLUMN_EXCEPTIONS에 등록된 prefix는 자동 오버라이드.
|
||||
"""
|
||||
p = prefix
|
||||
roles = {
|
||||
"feed": f"FICQ-{p}01.PV",
|
||||
"steam_op": f"TICA-{p}11A.OP",
|
||||
"steam_flow": f"FIQ-{p}15.PV",
|
||||
"reb_temp": f"TICA-{p}11A.PV",
|
||||
"T_B": f"TI-{p}11B.PV",
|
||||
"T_C": f"TI-{p}11C.PV",
|
||||
"T_D": f"TI-{p}11D.PV",
|
||||
"feed_preheat": f"TI-{p}03.PV",
|
||||
"vacuum": f"PICA-{p}11.PV",
|
||||
"dp": f"PI-{p}11B.PV",
|
||||
"product": f"FICQ-{p}18.PV",
|
||||
"reflux": f"FICQ-{p}13.PV",
|
||||
"light": f"FICQ-{p}14.PV",
|
||||
"heavy": f"FICQ-{p}16.PV",
|
||||
"reb_level": f"LI-{p}11.PV",
|
||||
"reflux_drum": f"LICA-{p}13.PV",
|
||||
}
|
||||
ov = COLUMN_EXCEPTIONS.get(prefix, {})
|
||||
roles.update(ov)
|
||||
return roles
|
||||
|
||||
|
||||
# C-6111 (6-1) 역할별 태그 — legacy 직접 참조 호환용
|
||||
ROLES = roles_for("61", ASSET)
|
||||
|
||||
|
||||
def resolve(conn, shorttags, asset=ASSET):
|
||||
"""shortptname 목록 -> {tag: (tblname, colnum)}"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT p.shortptname, t.tblname, m.oit
|
||||
FROM ptlist p JOIN mapping m ON m.pid=p.pid JOIN tblist t ON t.tid=m.tid
|
||||
WHERE p.asset=%s AND p.shortptname = ANY(%s)
|
||||
""", (asset, list(shorttags)))
|
||||
out = {}
|
||||
for short, tbl, oit in cur.fetchall():
|
||||
out[short] = (tbl, int(oit))
|
||||
return out
|
||||
|
||||
|
||||
def tag_frame(conn, role_map, asset=ASSET):
|
||||
"""{role: shorttag} -> dtat 인덱스 DataFrame(컬럼=role). 테이블별 1쿼리 후 merge."""
|
||||
loc = resolve(conn, role_map.values(), asset)
|
||||
missing = [r for r, t in role_map.items() if t not in loc]
|
||||
if missing:
|
||||
print(f"[warn] 미해결 태그: {[(r, role_map[r]) for r in missing]}", file=sys.stderr)
|
||||
# 테이블별 그룹
|
||||
by_tbl = {}
|
||||
for role, short in role_map.items():
|
||||
if short not in loc:
|
||||
continue
|
||||
tbl, col = loc[short]
|
||||
by_tbl.setdefault(tbl, []).append((role, col))
|
||||
df = None
|
||||
for tbl, cols in by_tbl.items():
|
||||
sel = ", ".join([f'col{c:02d} AS "{role}"' for role, c in cols])
|
||||
q = f"SELECT dtat, {sel} FROM {tbl}"
|
||||
part = pd.read_sql(q, conn)
|
||||
df = part if df is None else df.merge(part, on="dtat", how="outer")
|
||||
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
|
||||
reb, vac, steam, prod = df["reb_temp"], df["vacuum"], df["steam_op"], df["product"]
|
||||
hot_vac = (reb > 60) & (vac < 200) & (steam > 5) # 컬럼 가동(hot+진공)
|
||||
# 온도 추세(60분=120샘플 기울기)로 startup/shutdown 구분
|
||||
slope = reb.diff().rolling(120, min_periods=10, center=True).mean()
|
||||
mode = np.where(
|
||||
hot_vac,
|
||||
np.where(prod < 80, "LINEOUT", "PROD"), # 제품≈0 → 전환류/라인아웃
|
||||
np.where(slope > 0.02, "STARTUP",
|
||||
np.where(slope < -0.02, "SHUTDOWN", "STOPPED")))
|
||||
return pd.Series(mode, index=df.index, name="mode")
|
||||
|
||||
|
||||
def plot_timeline(df, png):
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
d = df.iloc[::30].copy() # 15분 다운샘플
|
||||
colors = {"PROD": "#2ca02c", "LINEOUT": "#ff7f0e", "STARTUP": "#1f77b4",
|
||||
"SHUTDOWN": "#d62728", "STOPPED": "#7f7f7f"}
|
||||
fig, ax = plt.subplots(5, 1, figsize=(16, 12), sharex=True)
|
||||
ax[0].plot(d.dtat, d.reb_temp, lw=.5, label="reb_temp(A)")
|
||||
ax[0].plot(d.dtat, d.T_C, lw=.5, label="T_C(민감단)")
|
||||
ax[0].plot(d.dtat, d.T_D, lw=.5, label="T_D(탑상)")
|
||||
ax[0].set_ylabel("온도"); ax[0].legend(loc="upper right", fontsize=7)
|
||||
ax[1].plot(d.dtat, d.feed, lw=.5, color="purple"); ax[1].set_ylabel("feed FICQ-6101")
|
||||
ax[2].plot(d.dtat, d["product"], lw=.5, color="orange"); ax[2].set_ylabel("측류제품 6118")
|
||||
ax[3].plot(d.dtat, d.steam_flow, lw=.5, color="red")
|
||||
ax[3].plot(d.dtat, d.steam_op * 10, lw=.5, color="brown", alpha=.5, label="OP×10")
|
||||
ax[3].set_ylabel("스팀유량/OP"); ax[3].legend(loc="upper right", fontsize=7)
|
||||
ax[4].plot(d.dtat, d.vacuum, lw=.5, color="teal"); ax[4].set_ylabel("진공 PICA-6111")
|
||||
ax[4].set_ylim(100, 130)
|
||||
# 모드 배경 음영
|
||||
for a in ax:
|
||||
for m, c in colors.items():
|
||||
seg = d[d["mode"] == m]
|
||||
a.scatter(seg.dtat, [a.get_ylim()[0]] * len(seg), c=c, s=2, marker="|")
|
||||
fig.suptitle("C-6111 (6-1차) 전체기간 — 운전모드별 (하단 컬러바)")
|
||||
fig.tight_layout()
|
||||
fig.savefig(png, dpi=90)
|
||||
print(f"플롯 저장: {png}")
|
||||
|
||||
|
||||
def main():
|
||||
with psycopg.connect(DSN) as conn:
|
||||
df = tag_frame(conn, ROLES)
|
||||
print(f"행수={len(df)} 기간={df.dtat.min()} ~ {df.dtat.max()}")
|
||||
print("\n=== 핵심 신호 분포 (운전모드 임계 설정용) ===")
|
||||
show = ["feed", "reb_temp", "vacuum", "product", "reflux", "steam_op",
|
||||
"steam_flow", "T_C", "T_D", "dp"]
|
||||
desc = df[show].describe(percentiles=[.01, .05, .25, .5, .75, .95, .99]).T
|
||||
print(desc[["min", "1%", "5%", "50%", "95%", "99%", "max"]].round(2).to_string())
|
||||
|
||||
df["mode"] = classify_phases(df)
|
||||
print("\n=== 운전모드 분포 (30초 샘플 기준) ===")
|
||||
vc = df["mode"].value_counts()
|
||||
for m, n in vc.items():
|
||||
print(f" {m:9s} {n:7d} {100*n/len(df):5.1f}% ≈ {n*30/3600:7.1f} h")
|
||||
|
||||
out = "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_data.pkl"
|
||||
df.to_pickle(out)
|
||||
plot_timeline(df, "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_timeline.png")
|
||||
print(f"저장: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
193
scripts/analysis/c6111_operator_assist.py
Normal file
193
scripts/analysis/c6111_operator_assist.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Operator-assist 패키징 (작업3).
|
||||
|
||||
사용법:
|
||||
python3 c6111_operator_assist.py --data c61_data.pkl --prefix c61
|
||||
python3 c6111_operator_assist.py --data c61_data.pkl --prefix c61 --live '{"feed":500,"product":300,"T_C":84.7}'
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import IsolationForest
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
PROD_SMOOTH = 40
|
||||
|
||||
|
||||
class OperatorAssist:
|
||||
def __init__(self, df):
|
||||
self.df = df
|
||||
self.mode = "UNKNOWN"
|
||||
self.model = None
|
||||
self.inv = None
|
||||
self.ood = None
|
||||
self.env_lo = None
|
||||
self.env_hi = None
|
||||
self._train()
|
||||
|
||||
def _train(self):
|
||||
prod = self.df[self.df["mode"] == "PROD"].copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)]
|
||||
prod = prod.dropna(subset=FEATURES + ["steam_op", "steam_flow"])
|
||||
if len(prod) < 100:
|
||||
print(" [WARN] PROD 데이터 부족 — advisory 신뢰도 낮음")
|
||||
points = (prod.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||
.dropna(subset=["steam_flow", "feed"]))
|
||||
points = points[points["feed"] > 50]
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
self.model = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||
learning_rate=0.05, random_state=0)
|
||||
self.model.fit(points[FEATURES].values, points["steam_flow"].values)
|
||||
self.inv = np.polyfit(prod["steam_flow"], prod["steam_op"], 3)
|
||||
self.env_lo = points[FEATURES].quantile(0.01)
|
||||
self.env_hi = points[FEATURES].quantile(0.99)
|
||||
self.ood = IsolationForest(contamination=0.05, random_state=0).fit(points[FEATURES].values)
|
||||
print(f" 학습 운전점: {len(points)}개 envelope:")
|
||||
for c in FEATURES:
|
||||
print(f" {c}: [{self.env_lo[c]:.0f}, {self.env_hi[c]:.1f}]")
|
||||
|
||||
def classify_mode(self, tags):
|
||||
"""tags dict → mode 추정 (classify_phases 단순 replica).
|
||||
|
||||
steam_op 없으면 feed/product로 판단 (live advisory용).
|
||||
"""
|
||||
prod = tags.get("product", 0)
|
||||
feed = tags.get("feed", 0)
|
||||
steam = tags.get("steam_op", None)
|
||||
reb = tags.get("reb_temp", 60)
|
||||
if prod > 100:
|
||||
if steam is None or steam > 10:
|
||||
return "PROD"
|
||||
if steam is not None:
|
||||
if steam > 10 and reb > 60:
|
||||
return "LINEOUT"
|
||||
if steam > 10 and feed < 50:
|
||||
return "STARTUP"
|
||||
if feed > 50:
|
||||
return "PROD" # fallback: steam_op 없이 feed>50 + product>100는 PROD
|
||||
return "STOPPED"
|
||||
|
||||
def in_envelope(self, tags):
|
||||
x = np.array([[tags[c] for c in FEATURES]])
|
||||
return ((x >= self.env_lo.values) & (x <= self.env_hi.values)).all()
|
||||
|
||||
def ood_score(self, tags):
|
||||
return self.ood.decision_function(np.array([[tags[c] for c in FEATURES]]))[0]
|
||||
|
||||
def predict(self, tags, smooth_history=None):
|
||||
"""live_tags dict → advisory dict.
|
||||
|
||||
tags: {"feed": float, "product": float, "T_C": float}
|
||||
smooth_history: optional list of prior tag dicts for causal smoothing
|
||||
|
||||
Returns:
|
||||
{"rec_OP": float or None, "rec_steam": float, "confidence": str,
|
||||
"mode": str, "ood": bool, "in_env": bool, "message": str}
|
||||
"""
|
||||
mode = self.classify_mode(tags)
|
||||
self.mode = mode
|
||||
env = self.in_envelope(tags)
|
||||
ood = self.ood_score(tags) < 0
|
||||
raw = np.array([[[tags[c] for c in FEATURES]]])
|
||||
|
||||
if mode != "PROD":
|
||||
msg = f"운전모드={mode} — advisory는 PROD에서만 제공 (STARTUP/LINEOUT은 레시피 참조)"
|
||||
return {"rec_OP": None, "rec_steam": None, "confidence": "N/A",
|
||||
"mode": mode, "ood": ood, "in_env": env, "message": msg}
|
||||
|
||||
# smooth: causal trailing median over recent history
|
||||
if smooth_history and len(smooth_history) >= PROD_SMOOTH:
|
||||
buf = pd.DataFrame(smooth_history[-PROD_SMOOTH:])[FEATURES].median()
|
||||
x = np.array([[buf[c] for c in FEATURES]])
|
||||
else:
|
||||
x = raw[0]
|
||||
|
||||
sf = self.model.predict(x)[0]
|
||||
op = np.clip(np.polyval(self.inv, sf), 0, 100)
|
||||
|
||||
if not env:
|
||||
confidence = "LOW_OOD"
|
||||
msg = (f"⚠ 범위밖 입력 — 권장 OP={op:.1f}% (외삽, 신뢰도 낮음). "
|
||||
"오퍼레이터 판단 우선")
|
||||
elif ood:
|
||||
confidence = "MEDIUM"
|
||||
msg = f"권장 OP={op:.1f}% (신뢰: 구간내, IForest 이상감지 — 주의)"
|
||||
else:
|
||||
confidence = "HIGH"
|
||||
msg = f"권장 OP={op:.1f}% (신뢰: 구간내)"
|
||||
|
||||
return {"rec_OP": round(op, 1), "rec_steam": round(sf, 1),
|
||||
"confidence": confidence, "mode": mode, "ood": bool(ood),
|
||||
"in_env": bool(env), "feed": float(x[0][0]),
|
||||
"product": float(x[0][1]), "T_C": float(x[0][2]),
|
||||
"message": msg}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
parser.add_argument("--live", help='JSON live_tags for single predict test')
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
assist = OperatorAssist(df)
|
||||
|
||||
if args.live:
|
||||
tags = json.loads(args.live)
|
||||
res = assist.predict(tags)
|
||||
print(f"\n=== Operator Advisory ({args.prefix}) ===")
|
||||
for k, v in res.items():
|
||||
print(f" {k:15s}: {v}")
|
||||
return
|
||||
|
||||
# 전체 shadow 리플레이: PROD 행 벡터화 처리
|
||||
prod = df[df["mode"] == "PROD"].sort_values("dtat").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) == 0:
|
||||
print(" PROD 없음 — advisory 불가")
|
||||
return
|
||||
|
||||
X = prod[FEATURES].values
|
||||
sf = assist.model.predict(X)
|
||||
op = np.clip(np.polyval(assist.inv, sf), 0, 100)
|
||||
env_mask = ((X >= assist.env_lo.values) & (X <= assist.env_hi.values)).all(axis=1)
|
||||
ood_mask = assist.ood.decision_function(X) < 0
|
||||
errors = op - prod["steam_op"].values
|
||||
|
||||
ood_rate = np.mean(ood_mask) * 100
|
||||
within_2 = np.mean(np.abs(errors) <= 2.0) * 100
|
||||
print(f"\n=== Shadow Advisory Report ({args.prefix}) ===")
|
||||
print(f" PROD 행수 : {len(prod)}")
|
||||
print(f" OOD 비율 : {ood_rate:.1f}%")
|
||||
print(f" OP MAE : {np.abs(errors).mean():.2f}%")
|
||||
print(f" |Δ|≤2% : {within_2:.1f}% (검증기준: 90%+ in-envelope)")
|
||||
env_only = errors[~ood_mask[:len(errors)]]
|
||||
if len(env_only):
|
||||
print(f" in-env MAE : {np.abs(env_only).mean():.2f}% "
|
||||
f"|Δ|≤2%={np.mean(np.abs(env_only)<=2)*100:.1f}%")
|
||||
|
||||
# 권장 OP vs 실제 OP 시계열 플롯
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
fig, ax = plt.subplots(2, 1, figsize=(14, 8))
|
||||
s = prod.iloc[::10]
|
||||
ax[0].plot(s["dtat"], s["steam_op"], lw=.6, label="actual OP")
|
||||
ax[0].plot(s["dtat"], op[::10], lw=.6, c="r", label="advisory OP")
|
||||
ax[0].set_ylabel("OP %"); ax[0].legend(fontsize=8)
|
||||
ax[0].set_title(f"Operator Advisory vs Actual OP ({args.prefix})")
|
||||
ax[1].hist(errors, bins=60)
|
||||
ax[1].axvline(0, c="k", lw=.5)
|
||||
ax[1].set_title(f"Advisory error (rec-actual): median {np.median(errors):+.2f}%, "
|
||||
f"within 2%={within_2:.1f}%")
|
||||
fig.tight_layout()
|
||||
path = BASE + f"{args.prefix}_advisory.png"
|
||||
fig.savefig(path, dpi=95)
|
||||
print(f"\n 플롯 저장: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
135
scripts/analysis/c6111_prodmap.py
Normal file
135
scripts/analysis/c6111_prodmap.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
① 생산 정상상태 맵.
|
||||
|
||||
PROD 구간에서 밸브특성 + 스팀유량 회귀.
|
||||
|
||||
선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함).
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.metrics import r2_score, mean_absolute_error
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
TARGET = "steam_flow"
|
||||
FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"]
|
||||
OP_RESAMPLE = "6h"
|
||||
|
||||
|
||||
def load(data_path=None):
|
||||
if data_path is None:
|
||||
data_path = BASE + "c6111_data.pkl"
|
||||
df = pd.read_pickle(data_path)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
# 엔지니어링 피처: 온도 구배(분리도)
|
||||
df["dT_AC"] = df["reb_temp"] - df["T_C"]
|
||||
df["dT_CD"] = df["T_C"] - df["T_D"]
|
||||
# 기본 정합성: 유량/유효범위 (센서 음수노이즈·결측 제거)
|
||||
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||
& df[FEATURES + [TARGET, "steam_op"]].notna().all(axis=1)]
|
||||
return df.sort_values("dtat").reset_index(drop=True)
|
||||
|
||||
|
||||
def valve_char(df):
|
||||
"""OP(밸브%) ↔ 스팀유량(FIQ-6115) 특성."""
|
||||
op, fl = df["steam_op"].values, df["steam_flow"].values
|
||||
# 선형게인
|
||||
a = np.polyfit(op, fl, 1)
|
||||
# 상승/하강 방향별(히스테리시스 ~ stiction 신호): OP 변화방향으로 분리
|
||||
dop = np.diff(df["steam_op"].values, prepend=df["steam_op"].values[0])
|
||||
up, dn = dop > 0.05, dop < -0.05
|
||||
# OP 빈(bin)별 유량 평균 — 같은 OP에서 상승/하강 유량차 = 히스테리시스
|
||||
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 = fl[m & up].mean() if (m & up).sum() > 5 else np.nan
|
||||
fd = fl[m & dn].mean() if (m & dn).sum() > 5 else np.nan
|
||||
rows.append((lo + .5, fl[m].mean(), fu, fd, m.sum()))
|
||||
hb = pd.DataFrame(rows, columns=["op", "flow", "flow_up", "flow_dn", "n"])
|
||||
hyst = (hb["flow_dn"] - hb["flow_up"]).abs().mean()
|
||||
print(f"[밸브] 선형 flow ≈ {a[0]:.1f}·OP + {a[1]:.1f} "
|
||||
f"(OP {op.min():.0f}~{op.max():.0f}%, flow {fl.min():.0f}~{fl.max():.0f})")
|
||||
print(f"[밸브] 상승/하강 평균 유량차(히스테리시스≈stiction) = {hyst:.1f} "
|
||||
f"(유량 스팬의 {100*hyst/(fl.max()-fl.min()):.1f}%)")
|
||||
return hb, a
|
||||
|
||||
|
||||
def regress(df):
|
||||
from sklearn.model_selection import train_test_split
|
||||
# 운전점 집계: 정상상태 내부 변동이 거의 없어(98% steady) 점단위 학습 불가.
|
||||
# 6h 중앙값 = 캠페인/로드레벨 단위 운전점 → 진짜 f(부하) 신호.
|
||||
ops = (df.set_index("dtat").resample(OP_RESAMPLE).median(numeric_only=True)
|
||||
.dropna(subset=[TARGET, "feed"]))
|
||||
ops = ops[ops["feed"] > 50]
|
||||
print(f"\n[운전점] PROD {len(df)}행 → {OP_RESAMPLE} 운전점 {len(ops)}개")
|
||||
X, y = ops[FEATURES].values, ops[TARGET].values
|
||||
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.3, random_state=0)
|
||||
|
||||
# 베이스라인: 피드만 (steam/feed 비율제어가 얼마나 설명?)
|
||||
lb = LinearRegression().fit(Xtr[:, :1], ytr)
|
||||
r2_feed = r2_score(yte, lb.predict(Xte[:, :1]))
|
||||
|
||||
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)
|
||||
span = y.max() - y.min()
|
||||
for name, pred in [("Linear", lin.predict(sc.transform(Xte))),
|
||||
("GBM", gbm.predict(Xte))]:
|
||||
print(f"[모델 {name:7s}] test R²(FIT)={r2_score(yte,pred):.3f} "
|
||||
f"MAE={mean_absolute_error(yte,pred):.1f} (스팬의 {100*mean_absolute_error(yte,pred)/span:.1f}%)")
|
||||
print(f"[베이스라인 피드단독] test R²={r2_feed:.3f} "
|
||||
f"steam/feed비 중앙값={(ops[TARGET]/ops['feed']).median():.3f}")
|
||||
|
||||
print("\n[피처 중요도]")
|
||||
coef = pd.Series(lin.coef_, index=FEATURES) # 표준화 → 상대중요도
|
||||
imp = pd.Series(gbm.feature_importances_, index=FEATURES)
|
||||
tbl = pd.DataFrame({"lin_std계수": coef.round(1),
|
||||
"GBM중요도": imp.round(3)}).sort_values("GBM중요도", ascending=False)
|
||||
print(tbl.to_string())
|
||||
return ops, gbm, Xte, yte, gbm.predict(Xte), imp
|
||||
|
||||
|
||||
def plots(hb, ops, yte, pred, imp, prefix="c6111"):
|
||||
fig, ax = plt.subplots(1, 4, figsize=(22, 5))
|
||||
ax[0].scatter(hb["op"], hb["flow"], s=20, c="k", label="mean")
|
||||
ax[0].plot(hb["op"], hb["flow_up"], "b.-", ms=4, label="OP rising")
|
||||
ax[0].plot(hb["op"], hb["flow_dn"], "r.-", ms=4, label="OP falling")
|
||||
ax[0].set_xlabel("steam OP %"); ax[0].set_ylabel("steam flow")
|
||||
ax[0].set_title("Valve char (hysteresis=stiction)"); ax[0].legend()
|
||||
ax[1].scatter(ops["feed"], ops[TARGET], s=10, alpha=.5)
|
||||
ax[1].set_xlabel("feed"); ax[1].set_ylabel("steam flow")
|
||||
ax[1].set_title("steam vs feed (operating points)")
|
||||
ax[2].scatter(yte, pred, s=12, alpha=.5)
|
||||
lim = [min(yte.min(), pred.min()), max(yte.max(), pred.max())]
|
||||
ax[2].plot(lim, lim, "r--"); ax[2].set_xlabel("actual steam flow")
|
||||
ax[2].set_ylabel("predicted (GBM)"); ax[2].set_title("Predicted vs Actual (test ops)")
|
||||
imp.sort_values().plot.barh(ax=ax[3]); ax[3].set_title("GBM feature importance")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{prefix}_prodmap.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{prefix}_prodmap.png")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = load(args.data)
|
||||
print(f"PROD 정합데이터 {len(df)}행")
|
||||
hb, a = valve_char(df)
|
||||
ops, gbm, Xte, yte, pred, imp = regress(df)
|
||||
plots(hb, ops, yte, pred, imp, args.prefix)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
85
scripts/analysis/c6111_rolling.py
Normal file
85
scripts/analysis/c6111_rolling.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모.
|
||||
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
from c6111_shadow import SteamPredictor, FEATURES, BASE, SMOOTH
|
||||
|
||||
HELDOUT_START = "2026-05-01"
|
||||
RETRAIN_EVERY = "1D"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
|
||||
# 인과(trailing) 평활 — 미래누설 없음
|
||||
for c in FEATURES:
|
||||
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1).median()
|
||||
|
||||
ho = pd.Timestamp(HELDOUT_START)
|
||||
if df["dtat"].max() < ho:
|
||||
print(f"데이터 종료 {df.dtat.max()} < HELDOUT_START({ho}) — 롤링 재학습 불가. (컬럼 가동기간이 5월 이전)")
|
||||
return
|
||||
|
||||
days = pd.date_range(ho, df["dtat"].max(), freq=RETRAIN_EVERY)
|
||||
|
||||
# 정적 모델: 5월 이전 전체로 1회 학습
|
||||
static = SteamPredictor().fit(df[df["dtat"] < ho])
|
||||
slo, shi = (df[df["dtat"] < ho][FEATURES].quantile(0.01),
|
||||
df[df["dtat"] < ho][FEATURES].quantile(0.99))
|
||||
|
||||
rows = []
|
||||
for d0, d1 in zip(days[:-1], days[1:]):
|
||||
day = df[(df["dtat"] >= d0) & (df["dtat"] < d1)]
|
||||
if len(day) < 30:
|
||||
continue
|
||||
train = df[df["dtat"] < d0] # expanding: 그 날 이전 전체
|
||||
roll = SteamPredictor().fit(train)
|
||||
lo, hi = train[FEATURES].quantile(0.01), train[FEATURES].quantile(0.99)
|
||||
Xs = day[[c + "_s" for c in FEATURES]].values
|
||||
ao = day["steam_op"].values
|
||||
po_r = roll.flow_to_op(roll.predict_flow(Xs))
|
||||
po_s = static.flow_to_op(static.predict_flow(Xs))
|
||||
ood_r = (~((day[FEATURES] >= lo) & (day[FEATURES] <= hi)).all(axis=1)).mean()
|
||||
rows.append(dict(day=d0,
|
||||
mae_roll=mean_absolute_error(ao, po_r),
|
||||
mae_static=mean_absolute_error(ao, po_s),
|
||||
w2_roll=np.mean(np.abs(po_r - ao) <= 2) * 100,
|
||||
w2_static=np.mean(np.abs(po_s - ao) <= 2) * 100,
|
||||
ood_roll=ood_r * 100))
|
||||
r = pd.DataFrame(rows)
|
||||
|
||||
print(f"=== 5월 held-out, 일별 walk-forward 재학습 ({len(r)}일) ===")
|
||||
print(f"정적 모델 : OP MAE {r.mae_static.mean():.2f}% |Δ|≤2% {r.w2_static.mean():.1f}%")
|
||||
print(f"롤링 모델 : OP MAE {r.mae_roll.mean():.2f}% |Δ|≤2% {r.w2_roll.mean():.1f}%")
|
||||
print(f"롤링 OOD 비율: 첫주 {r.head(7).ood_roll.mean():.0f}% → 마지막주 {r.tail(7).ood_roll.mean():.0f}%")
|
||||
print("\n일별(요약):")
|
||||
print(r[["day", "mae_static", "mae_roll", "w2_roll", "ood_roll"]]
|
||||
.assign(day=r.day.dt.strftime("%m-%d")).round(1).to_string(index=False))
|
||||
|
||||
fig, ax = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
|
||||
ax[0].plot(r.day, r.mae_static, "r.-", label="static (Feb-Apr model)")
|
||||
ax[0].plot(r.day, r.mae_roll, "g.-", label="rolling retrain")
|
||||
ax[0].axhline(2, color="gray", ls=":", label="2% 허용")
|
||||
ax[0].set_ylabel("OP MAE %"); ax[0].legend(); ax[0].set_title("Rolling vs static — adaptation over May")
|
||||
ax[1].plot(r.day, r.ood_roll, "b.-"); ax[1].set_ylabel("rolling OOD %")
|
||||
ax[1].set_title("OOD fraction (학습 envelope 밖) — 5월 데이터 흡수하며 감소")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_rolling.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_rolling.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
100
scripts/analysis/c6111_shadow.py
Normal file
100
scripts/analysis/c6111_shadow.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Shadow 예측기 — 히스토리 리플레이 백테스트.
|
||||
|
||||
선행: c6111_data.pkl. 형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
from sklearn.metrics import r2_score, mean_absolute_error
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
SMOOTH = 40
|
||||
TRAIN_FRAC = 0.70
|
||||
|
||||
|
||||
class SteamPredictor:
|
||||
"""운전점 학습 + 밸브 역특성(flow→OP)."""
|
||||
def fit(self, df_train):
|
||||
ops = (df_train.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||
.dropna(subset=["steam_flow", "feed"]))
|
||||
ops = ops[ops["feed"] > 50]
|
||||
self.model = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||
learning_rate=0.05, random_state=0)
|
||||
self.model.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
# 밸브 역특성: OP = poly(flow) (단조, 3차)
|
||||
self.inv = np.polyfit(df_train["steam_flow"], df_train["steam_op"], 3)
|
||||
return self
|
||||
|
||||
def predict_flow(self, X):
|
||||
return self.model.predict(X)
|
||||
|
||||
def flow_to_op(self, flow):
|
||||
return np.clip(np.polyval(self.inv, flow), 0, 100)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
|
||||
# 입력 평활 (실제 shadow도 노이즈 평활 사용)
|
||||
for c in FEATURES:
|
||||
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1, center=True).median()
|
||||
|
||||
cut = df["dtat"].quantile(TRAIN_FRAC)
|
||||
tr, te = df[df["dtat"] <= cut], df[df["dtat"] > cut]
|
||||
print(f"학습 {tr.dtat.min()}~{tr.dtat.max()} ({len(tr)}) "
|
||||
f"shadow(held-out) {te.dtat.min()}~{te.dtat.max()} ({len(te)})")
|
||||
|
||||
pred = SteamPredictor().fit(tr)
|
||||
# OOD(학습 운전envelope 밖) 게이트: 입력이 학습 1~99% 범위 밖이면 '저신뢰→오퍼레이터 폴백'
|
||||
lo, hi = tr[FEATURES].quantile(0.01), tr[FEATURES].quantile(0.99)
|
||||
print(f"학습 envelope: " + ", ".join(f"{c}[{lo[c]:.0f},{hi[c]:.1f}]" for c in FEATURES))
|
||||
|
||||
def in_env(d):
|
||||
return ((d[FEATURES] >= lo) & (d[FEATURES] <= hi)).all(axis=1)
|
||||
|
||||
for name, d in [("학습기간", tr), ("★held-out shadow", te)]:
|
||||
Xs = d[[c + "_s" for c in FEATURES]].values
|
||||
pf = pred.predict_flow(Xs)
|
||||
po = pred.flow_to_op(pf)
|
||||
ao = d["steam_op"].values
|
||||
env = in_env(d).values
|
||||
within = np.mean(np.abs(po - ao) <= 2.0) * 100
|
||||
print(f"\n[{name}] OOD(범위밖)={100*(~env).mean():.1f}%")
|
||||
print(f" 전체 OP MAE={mean_absolute_error(ao,po):.2f} |Δ|≤2%={within:.1f}%")
|
||||
if env.sum() > 50:
|
||||
print(f" in-envelope OP MAE={mean_absolute_error(ao[env],po[env]):.2f} "
|
||||
f"|Δ|≤2%={np.mean(np.abs(po[env]-ao[env])<=2)*100:.1f}% ← shadow가 신뢰구간에서 조언")
|
||||
d = d.assign(pred_flow=pf, pred_op=po, ood=~env)
|
||||
if name.startswith("★"):
|
||||
te = d
|
||||
|
||||
# 플롯: held-out 시계열 오버레이 + OP 비교 + 오차분포
|
||||
fig, ax = plt.subplots(3, 1, figsize=(16, 11))
|
||||
s = te.iloc[::20]
|
||||
ax[0].plot(s.dtat, s["steam_flow"], lw=.6, label="actual steam flow")
|
||||
ax[0].plot(s.dtat, s["pred_flow"], lw=.6, c="r", label="predicted")
|
||||
ax[0].set_title("held-out shadow: steam flow actual vs predicted"); ax[0].legend(fontsize=8)
|
||||
ax[1].plot(s.dtat, s["steam_op"], lw=.6, label="actual operator OP")
|
||||
ax[1].plot(s.dtat, s["pred_op"], lw=.6, c="r", label="predicted OP")
|
||||
ax[1].set_ylabel("OP %"); ax[1].set_title("operator OP vs shadow-predicted OP"); ax[1].legend(fontsize=8)
|
||||
err = te["pred_op"] - te["steam_op"]
|
||||
ax[2].hist(err, bins=80); ax[2].axvline(0, c="k", lw=.5)
|
||||
ax[2].set_title(f"OP error (pred-actual): median {err.median():+.2f}%, std {err.std():.2f}%")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_shadow.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_shadow.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
148
scripts/analysis/c6111_shutdown.py
Normal file
148
scripts/analysis/c6111_shutdown.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
③ SHUTDOWN 절차 학습 (few-shot). startup의 역순.
|
||||
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
|
||||
|
||||
def detect_cutoffs(df):
|
||||
"""★제품 컷오프★ 이벤트: product >100→<50 하강엣지이고 직후 steam도 하강(shutdown)."""
|
||||
prod = df["product"].values
|
||||
steam_op = df["steam_op"].values
|
||||
reb = df["reb_temp"].values
|
||||
outs = []
|
||||
i = 60
|
||||
n = len(df)
|
||||
while i < n:
|
||||
if prod[i] < 50 and prod[i-1] >= 100 and reb[i] > 60:
|
||||
fwd = steam_op[i:min(n, i+60)]
|
||||
if np.nanmean(fwd) < np.nanmean(steam_op[max(0, i-60):i]) * 0.8:
|
||||
outs.append(i)
|
||||
i += 720
|
||||
continue
|
||||
i += 1
|
||||
return outs
|
||||
|
||||
|
||||
def shutdown_milestones(df, co):
|
||||
"""컷오프 인덱스 co 기준 역방향 절차 추출."""
|
||||
tc = df["dtat"].iloc[co]
|
||||
n = len(df)
|
||||
|
||||
def mins(i):
|
||||
return None if i is None else (df["dtat"].iloc[i] - tc).total_seconds() / 60
|
||||
|
||||
feed_start = None
|
||||
feed_vals = df["feed"].values
|
||||
for j in range(co, max(0, co - 1200), -1):
|
||||
if feed_vals[j] < 100:
|
||||
feed_start = j
|
||||
if feed_start is not None and j > 0:
|
||||
if feed_vals[j] > feed_vals[min(j + 30, co)] * 0.85:
|
||||
continue
|
||||
if feed_vals[j] > 250 and feed_vals[j] > feed_vals[min(j + 1, co)] * 0.98:
|
||||
feed_start = j
|
||||
break
|
||||
|
||||
steam_off = None
|
||||
for j in range(co, min(n, co + 600)):
|
||||
if df["steam_op"].iloc[j] < 5:
|
||||
steam_off = j
|
||||
break
|
||||
|
||||
vacuum_off = None
|
||||
for j in range(co, min(n, co + 1200)):
|
||||
if df["vacuum"].iloc[j] > 300:
|
||||
vacuum_off = j
|
||||
break
|
||||
|
||||
prod_off = None
|
||||
for j in range(co, min(n, co + 120)):
|
||||
if df["product"].iloc[j] < 10:
|
||||
prod_off = j
|
||||
break
|
||||
|
||||
cold = None
|
||||
for j in range(co, min(n, co + 2400)):
|
||||
if df["reb_temp"].iloc[j] < 40:
|
||||
cold = j
|
||||
break
|
||||
|
||||
r = df.iloc[co]
|
||||
return dict(cutoff_time=tc,
|
||||
feed_to_cutoff=-(mins(feed_start)) if feed_start is not None else None,
|
||||
cutoff_to_steam_off=mins(steam_off) if steam_off else None,
|
||||
cutoff_to_vacuum_off=mins(vacuum_off) if vacuum_off else None,
|
||||
cutoff_to_prod_off=mins(prod_off) if prod_off else None,
|
||||
cutoff_to_cold=mins(cold) if cold else None,
|
||||
cutoff_rebA=r["reb_temp"], cutoff_TC=r["T_C"], cutoff_TD=r["T_D"],
|
||||
cutoff_dT_AD=r["reb_temp"] - r["T_D"])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data).sort_values("dtat").reset_index(drop=True)
|
||||
cutoffs = detect_cutoffs(df)
|
||||
print(f"탐지된 ★제품 컷오프★(shutdown 진입) 이벤트: {len(cutoffs)}개")
|
||||
|
||||
if not cutoffs:
|
||||
print(" [skip] shutdown 이벤트 없음 — 플롯 생략")
|
||||
return
|
||||
rows, windows = [], []
|
||||
for co in cutoffs:
|
||||
w = df.iloc[max(0, co - 360):min(len(df), co + 360)].copy()
|
||||
w["rel_min"] = (w["dtat"] - df["dtat"].iloc[co]).dt.total_seconds() / 60
|
||||
windows.append(w)
|
||||
rows.append(shutdown_milestones(df, co))
|
||||
M = pd.DataFrame(rows)
|
||||
pd.set_option("display.width", 220)
|
||||
print("\n=== 제품컷오프 기준 절차(분) + 셧다운 시점 컬럼상태 ===")
|
||||
cols = ["cutoff_time", "feed_to_cutoff", "cutoff_to_steam_off",
|
||||
"cutoff_to_vacuum_off", "cutoff_to_prod_off", "cutoff_to_cold",
|
||||
"cutoff_rebA", "cutoff_TC", "cutoff_dT_AD"]
|
||||
show = M[cols].copy()
|
||||
show["cutoff_time"] = show["cutoff_time"].dt.strftime("%m-%d %H:%M")
|
||||
print(show.round(1).to_string(index=False))
|
||||
print("\n=== 셧다운 레시피(중앙값) ===")
|
||||
print(f" 피드감소→컷오프: {M.feed_to_cutoff.median():.0f}분")
|
||||
print(f" 컷오프→스팀차단 : {M.cutoff_to_steam_off.median():.0f}분")
|
||||
print(f" 컷오프→진공해제 : {M.cutoff_to_vacuum_off.median():.0f}분")
|
||||
print(f" 컷오프→제품0 : {M.cutoff_to_prod_off.median():.0f}분")
|
||||
print(f" 컷오프→냉각 : {M.cutoff_to_cold.median():.0f}분")
|
||||
reb_std = M.cutoff_rebA.std() if len(M) > 1 else 0.0
|
||||
tc_std = M.cutoff_TC.std() if len(M) > 1 else 0.0
|
||||
print(f" ★셧다운 트리거: reb-A={M.cutoff_rebA.median():.1f}±{reb_std:.1f}℃, "
|
||||
f"T_C={M.cutoff_TC.median():.1f}±{tc_std:.2f}℃, ΔT(A-D)={M.cutoff_dT_AD.median():.1f}℃")
|
||||
|
||||
fig, ax = plt.subplots(4, 1, figsize=(13, 11), sharex=True)
|
||||
for k, w in enumerate(windows):
|
||||
c = plt.cm.tab10(k)
|
||||
ax[0].plot(w.rel_min, w.reb_temp, color=c, lw=.9, label=f"sh{k+1} {w.dtat.iloc[len(w)//2]:%m-%d}")
|
||||
ax[0].plot(w.rel_min, w["T_D"], color=c, lw=.6, ls=":")
|
||||
ax[1].plot(w.rel_min, w.steam_flow, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w.reflux, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w["product"], color=c, lw=.9, ls="--")
|
||||
ax[3].plot(w.rel_min, w.feed, color=c, lw=.9)
|
||||
ax[0].set_ylabel("reb_temp/T_D(:)"); ax[0].legend(fontsize=7)
|
||||
ax[0].set_title("SHUTDOWN aligned at PRODUCT CUT-OFF (rel=0)")
|
||||
ax[1].set_ylabel("steam flow"); ax[2].set_ylabel("reflux/product(--)")
|
||||
ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-off")
|
||||
for a in ax:
|
||||
a.axvline(0, c="k", lw=.5)
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_shutdown.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_shutdown.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
111
scripts/analysis/c6111_startup.py
Normal file
111
scripts/analysis/c6111_startup.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
② START-UP 절차 학습 (few-shot).
|
||||
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
|
||||
|
||||
def detect_cutins(df):
|
||||
"""★제품 컷인★ 이벤트: product 0→>100 상향, 직전 30분 라인아웃(product<50)이고 hot(reb>75)."""
|
||||
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] # 직전 30분
|
||||
if np.nanmedian(pre) < 50 and reb[i] > 75: # 라인아웃(제품off)+hot
|
||||
outs.append(i)
|
||||
i += 720
|
||||
continue
|
||||
i += 1
|
||||
return outs
|
||||
|
||||
|
||||
def milestones(df, ci):
|
||||
"""제품 컷인 인덱스 ci 기준 절차 추출."""
|
||||
tc = df["dtat"].iloc[ci]
|
||||
# 역방향: 스팀투입(steam_op>10 연속 시작) — 컷인 직전 steam off→on
|
||||
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]
|
||||
# 리플럭스 확립(스팀투입 이후 reflux>100 첫)
|
||||
aft = df.iloc[i_steam:ci]
|
||||
r_on = aft[aft["reflux"] > 100]
|
||||
i_refl = r_on.index[0] if len(r_on) else None
|
||||
# 풀로드(컷인 이후 feed>250 첫)
|
||||
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 dict(cutin_time=tc,
|
||||
steam_to_cutin=-mins(i_steam),
|
||||
reflux_to_cutin=(-mins(i_refl) if i_refl is not None else None),
|
||||
cutin_to_full=mins(i_full),
|
||||
cutin_rebA=r["reb_temp"], cutin_TC=r["T_C"], cutin_TD=r["T_D"],
|
||||
cutin_dT_AD=r["reb_temp"]-r["T_D"])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data).sort_values("dtat").reset_index(drop=True)
|
||||
cutins = detect_cutins(df)
|
||||
print(f"탐지된 ★제품 컷인★(진짜 startup) 이벤트: {len(cutins)}개")
|
||||
|
||||
rows, windows = [], []
|
||||
for ci in cutins:
|
||||
w = df.iloc[max(0, ci-360):min(len(df), ci+360)].copy() # 컷인 ±3h
|
||||
w["rel_min"] = (w["dtat"] - df["dtat"].iloc[ci]).dt.total_seconds()/60
|
||||
windows.append(w)
|
||||
rows.append(milestones(df, ci))
|
||||
M = pd.DataFrame(rows)
|
||||
pd.set_option("display.width", 220)
|
||||
print("\n=== 제품컷인 기준 절차(분) + 컷인 시점 컬럼상태 ===")
|
||||
cols = ["cutin_time", "steam_to_cutin", "reflux_to_cutin", "cutin_to_full",
|
||||
"cutin_rebA", "cutin_TC", "cutin_dT_AD"]
|
||||
show = M[cols].copy()
|
||||
show["cutin_time"] = show["cutin_time"].dt.strftime("%m-%d %H:%M")
|
||||
print(show.round(1).to_string(index=False))
|
||||
print("\n=== 절차 레시피(중앙값) ===")
|
||||
print(f" 스팀투입→제품컷인(전환류 라인아웃 길이): {M.steam_to_cutin.median():.0f}분")
|
||||
print(f" 리플럭스확립→제품컷인 : {M.reflux_to_cutin.median():.0f}분")
|
||||
print(f" 제품컷인→풀로드 : {M.cutin_to_full.median():.0f}분")
|
||||
print(f" ★제품컷인 트리거(컬럼상태): reb-A={M.cutin_rebA.median():.1f}±{M.cutin_rebA.std():.1f}℃, "
|
||||
f"T_C={M.cutin_TC.median():.1f}±{M.cutin_TC.std():.2f}℃, ΔT(A-D)={M.cutin_dT_AD.median():.1f}℃")
|
||||
|
||||
fig, ax = plt.subplots(4, 1, figsize=(13, 11), sharex=True)
|
||||
for k, w in enumerate(windows):
|
||||
c = plt.cm.tab10(k)
|
||||
ax[0].plot(w.rel_min, w.reb_temp, color=c, lw=.9, label=f"ep{k+1} {w.dtat.iloc[len(w)//2]:%m-%d}")
|
||||
ax[0].plot(w.rel_min, w["T_D"], color=c, lw=.6, ls=":")
|
||||
ax[1].plot(w.rel_min, w.steam_flow, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w.reflux, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w["product"], color=c, lw=.9, ls="--")
|
||||
ax[3].plot(w.rel_min, w.feed, color=c, lw=.9)
|
||||
ax[0].set_ylabel("reb_temp/T_D(:)"); ax[0].legend(fontsize=7)
|
||||
ax[0].set_title("STARTUP aligned at PRODUCT CUT-IN (rel=0)")
|
||||
ax[1].set_ylabel("steam flow"); ax[2].set_ylabel("reflux/product(--)")
|
||||
ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-in")
|
||||
for a in ax:
|
||||
a.axvline(0, c="k", lw=.5)
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_startup.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_startup.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
92
scripts/analysis/c6111_trim.py
Normal file
92
scripts/analysis/c6111_trim.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
C-6111 캠페인내 온도 트림 = gentle 피드백 추출 (플랜 §5, §16.8-(3)).
|
||||
|
||||
정상맵(전향)은 운전점 단위 steam=f(부하)만 잡는다. 캠페인 내부에서 오퍼레이터가
|
||||
T_C 작은 편차에 스팀(OP)을 어떻게 미세조정하는지 = 피드백 정책을 데이터에서 추출:
|
||||
- 부하 일정 구간만(전향/부하응답과 분리)
|
||||
- 목표 T_C = 느린 인과 베이스라인, 오차 err = T_C - 목표
|
||||
- OP 변경 이벤트의 트리거 오차(데드밴드), 변경크기 vs 오차(게인), 이벤트 간격(dwell)
|
||||
→ §5 anti-hunting 제어(데드밴드+게인+dwell)의 현장 파라미터.
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
TGT_WIN = 360 # 목표 T_C 베이스라인 3h(인과 trailing)
|
||||
LOAD_WIN = 120 # 부하 일정 판정 1h
|
||||
LOAD_STD_MAX = 15 # feed rolling std 임계(저변동=캠페인 내부)
|
||||
MOVE = 0.1 # OP 변경 인식 임계(%)
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_pickle(BASE + "c6111_data.pkl")
|
||||
df = df[df["mode"] == "PROD"].copy().sort_values("dtat").reset_index(drop=True)
|
||||
df = df[(df["feed"] > 50) & (df["steam_op"] > 1)]
|
||||
|
||||
# 목표 T_C(느린 인과 베이스라인) 및 오차
|
||||
df["tc_tgt"] = df["T_C"].rolling(TGT_WIN, min_periods=30).median()
|
||||
df["err"] = df["T_C"] - df["tc_tgt"]
|
||||
df["dop"] = df["steam_op"].diff()
|
||||
# 부하 일정 구간(전향 부하응답 제외 → 순수 피드백 트림)
|
||||
df["feed_std"] = df["feed"].rolling(LOAD_WIN, min_periods=30).std()
|
||||
steady = df[(df["feed_std"] < LOAD_STD_MAX) & df["err"].notna()].copy()
|
||||
print(f"PROD {len(df)} → 부하일정 정상구간 {len(steady)} ({100*len(steady)/len(df):.0f}%)")
|
||||
|
||||
# 제어 성능: 목표 대비 T_C 유지 밴드
|
||||
e = steady["err"]
|
||||
print(f"\n[T_C 유지] 오차 std={e.std():.3f}℃ p5/p95=[{e.quantile(.05):+.2f},{e.quantile(.95):+.2f}]℃")
|
||||
|
||||
# OP 변경 이벤트
|
||||
mv = steady[steady["dop"].abs() > MOVE].copy()
|
||||
# 직전 오차(트리거)
|
||||
mv["err_trig"] = steady["err"].shift(1).loc[mv.index]
|
||||
print(f"[OP 이동] 정상구간 {len(steady)}샘플 중 이동 {len(mv)}회 "
|
||||
f"(평탄율 {100*(1-len(mv)/len(steady)):.1f}%)")
|
||||
|
||||
# 데드밴드: 이동시 |오차| vs 비이동시 |오차|
|
||||
nomv = steady[steady["dop"].abs() <= MOVE]
|
||||
print(f"[데드밴드] 이동시 |오차| median={mv['err_trig'].abs().median():.3f}℃ "
|
||||
f"비이동시 |오차| median={nomv['err'].abs().median():.3f}℃")
|
||||
print(f" 이동의 75%는 |오차|>{mv['err_trig'].abs().quantile(.25):.3f}℃ 에서 발생")
|
||||
|
||||
# 피드백 게인: dOP vs 트리거오차 (음의 기울기 기대)
|
||||
g = mv.dropna(subset=["err_trig"])
|
||||
g = g[g["err_trig"].abs() < 2] # 이상치 제외
|
||||
if len(g) > 30:
|
||||
a = np.polyfit(g["err_trig"], g["dop"], 1)
|
||||
r = np.corrcoef(g["err_trig"], g["dop"])[0, 1]
|
||||
print(f"[피드백 게인] dOP ≈ {a[0]:+.2f}·오차 {a[1]:+.2f} (corr={r:+.2f}, "
|
||||
f"음수=음의피드백: 온도↑→스팀↓)")
|
||||
|
||||
# dwell: 이동 간격(샘플)
|
||||
dwell = np.diff(mv.index.values)
|
||||
dwell = dwell[dwell > 0]
|
||||
if len(dwell):
|
||||
print(f"[dwell] 이동 간격 중앙 {np.median(dwell)*30/60:.0f}분 "
|
||||
f"p25/p75=[{np.percentile(dwell,25)*30/60:.0f},{np.percentile(dwell,75)*30/60:.0f}]분")
|
||||
|
||||
# 플롯
|
||||
fig, ax = plt.subplots(2, 2, figsize=(14, 9))
|
||||
ax[0, 0].hist(e.dropna(), bins=100); ax[0, 0].axvline(0, c="k", lw=.5)
|
||||
ax[0, 0].set_title(f"T_C error band (std {e.std():.3f}C)"); ax[0, 0].set_xlim(-1, 1)
|
||||
ax[0, 1].hist(mv["err_trig"].abs().dropna(), bins=60, alpha=.6, density=True, label="at move")
|
||||
ax[0, 1].hist(nomv["err"].abs().dropna(), bins=60, alpha=.6, density=True, label="no move")
|
||||
ax[0, 1].set_title("deadband: |error| at move vs no-move"); ax[0, 1].set_xlim(0, 1); ax[0, 1].legend()
|
||||
if len(g) > 30:
|
||||
ax[1, 0].scatter(g["err_trig"], g["dop"], s=5, alpha=.3)
|
||||
xs = np.linspace(g["err_trig"].min(), g["err_trig"].max(), 50)
|
||||
ax[1, 0].plot(xs, np.polyval(a, xs), "r-")
|
||||
ax[1, 0].set_xlabel("T_C error trigger"); ax[1, 0].set_ylabel("dOP")
|
||||
ax[1, 0].set_title(f"feedback gain dOP/err = {a[0]:+.2f}")
|
||||
if len(dwell):
|
||||
ax[1, 1].hist(dwell * 30 / 60, bins=60); ax[1, 1].set_xlabel("dwell min")
|
||||
ax[1, 1].set_title("dwell between OP moves"); ax[1, 1].set_xlim(0, 300)
|
||||
fig.tight_layout(); fig.savefig(BASE + "c6111_trim.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}c6111_trim.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
scripts/analysis/daily_report.py
Normal file
78
scripts/analysis/daily_report.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
컬럼 일별 리포트 — 지정 기간 일별 운전값 + 생산맵 예측품질(shadow).
|
||||
|
||||
사용: python3 daily_report.py --prefix 62 --asset /ASSETS/P6 \
|
||||
--start 2026-04-01 --end 2026-06-01 --train-end 2026-04-01
|
||||
|
||||
train-end 이전 PROD로 생산맵 학습 → start~end 일별로 예측 OP vs 실제 비교(전진 shadow).
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import psycopg
|
||||
from c6111_extract import roles_for, tag_frame, classify_phases, DSN
|
||||
from c6111_shadow import SteamPredictor, FEATURES, SMOOTH
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--prefix", default="62")
|
||||
ap.add_argument("--asset", default="/ASSETS/P6")
|
||||
ap.add_argument("--start", default="2026-04-01")
|
||||
ap.add_argument("--end", default="2026-06-01")
|
||||
ap.add_argument("--train-end", default="2026-04-01")
|
||||
a = ap.parse_args()
|
||||
|
||||
with psycopg.connect(DSN) as conn:
|
||||
df = tag_frame(conn, roles_for(a.prefix, a.asset), a.asset)
|
||||
df["mode"] = classify_phases(df)
|
||||
for c in FEATURES:
|
||||
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1).median()
|
||||
|
||||
prod = df[df["mode"] == "PROD"].copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)]
|
||||
te = pd.Timestamp(a.train_end)
|
||||
train = prod[prod["dtat"] < te]
|
||||
print(f"전체 {df.dtat.min()}~{df.dtat.max()}, PROD {len(prod)}행. "
|
||||
f"학습(<{a.train_end}) {len(train)}행")
|
||||
|
||||
pred = SteamPredictor().fit(train) if len(train) > 500 else None
|
||||
if pred:
|
||||
lo, hi = train[FEATURES].quantile(0.01), train[FEATURES].quantile(0.99)
|
||||
|
||||
s, e = pd.Timestamp(a.start), pd.Timestamp(a.end)
|
||||
win = df[(df["dtat"] >= s) & (df["dtat"] < e)]
|
||||
rows = []
|
||||
for day, g in win.groupby(win["dtat"].dt.date):
|
||||
gp = g[(g["mode"] == "PROD") & (g["feed"] > 50) & (g["steam_flow"] > 10) & (g["steam_op"] > 1)]
|
||||
r = dict(date=day, n=len(g),
|
||||
PROD=round(100*(g["mode"] == "PROD").mean()),
|
||||
feed=gp["feed"].median(), steam=gp["steam_flow"].median(),
|
||||
ratio=round((gp["steam_flow"]/gp["feed"]).median(), 3) if len(gp) else np.nan,
|
||||
T_C=round(gp["T_C"].median(), 2) if len(gp) else np.nan,
|
||||
OP=round(gp["steam_op"].median(), 1) if len(gp) else np.nan)
|
||||
if pred and len(gp) > 5:
|
||||
Xs = gp[[c + "_s" for c in FEATURES]].values
|
||||
po = pred.flow_to_op(pred.predict_flow(Xs))
|
||||
ao = gp["steam_op"].values
|
||||
env = ((gp[FEATURES] >= lo) & (gp[FEATURES] <= hi)).all(axis=1)
|
||||
r["predOP_MAE"] = round(mean_absolute_error(ao, po), 2)
|
||||
r["within2%"] = round(100*np.mean(np.abs(po-ao) <= 2))
|
||||
r["OOD%"] = round(100*(~env).mean())
|
||||
rows.append(r)
|
||||
R = pd.DataFrame(rows)
|
||||
pd.set_option("display.width", 200)
|
||||
print(f"\n=== {a.prefix} 일별 리포트 ({a.start}~{a.end}) ===")
|
||||
R["date"] = R["date"].astype(str)
|
||||
print(R.round(1).to_string(index=False))
|
||||
print("\n=== 요약 ===")
|
||||
print(f" feed 중앙 {R.feed.median():.0f}, steam 중앙 {R.steam.median():.0f}, "
|
||||
f"steam/feed비 중앙 {R.ratio.median():.3f}")
|
||||
if "predOP_MAE" in R:
|
||||
print(f" 예측 OP MAE 중앙 {R.predOP_MAE.median():.2f}%, "
|
||||
f"within2% 중앙 {R['within2%'].median():.0f}%, OOD 중앙 {R['OOD%'].median():.0f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
404
scripts/analysis/export_plotdata.py
Normal file
404
scripts/analysis/export_plotdata.py
Normal file
@@ -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()
|
||||
85
scripts/analysis/gen_instrument_ranges.py
Normal file
85
scripts/analysis/gen_instrument_ranges.py
Normal file
@@ -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()
|
||||
99
scripts/analysis/gen_temp_profiles.py
Normal file
99
scripts/analysis/gen_temp_profiles.py
Normal file
@@ -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()
|
||||
2722
scripts/analysis/instrument_ranges.json
Normal file
2722
scripts/analysis/instrument_ranges.json
Normal file
File diff suppressed because it is too large
Load Diff
180
scripts/analysis/run_column.py
Normal file
180
scripts/analysis/run_column.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
형제 컬럼 확장(작업1) 일괄 실행 래퍼.
|
||||
|
||||
사용법:
|
||||
python3 run_column.py --prefix 62 # 6-2차 단독
|
||||
python3 run_column.py --prefix 81 --asset /ASSETS/P8
|
||||
python3 run_column.py --all # 모든 형제 컬럼
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import psycopg
|
||||
import pandas as pd
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
COLUMNS = [
|
||||
("51", "/ASSETS/P5", "C-5111 (5차)"), # 측류 솔벤트, T_C 센서 부재(대체)
|
||||
("61", "/ASSETS/P6", "C-6111 (6-1차)"),
|
||||
("62", "/ASSETS/P6", "C-6211 (6-2차)"),
|
||||
("81", "/ASSETS/P8", "C-8111 (8차, 단일 train)"),
|
||||
("91", "/ASSETS/P9", "C-9111 (9-1차)"),
|
||||
("92", "/ASSETS/P9", "C-9211 (9-2차)"),
|
||||
("101", "/ASSETS/P10", "C-10111 (10-1차) ※본 dump엔 시운전기간 多, 생산샘플 적음"),
|
||||
("102", "/ASSETS/P10", "C-10211 (10-2차) ※첫생산 5/26~, 이후 데이터 유효"),
|
||||
]
|
||||
# ※ 10차는 본 dump 창(2026-02~06) 대부분이 시운전이라 PROD 샘플이 적음(10-1 2%, 10-2 5/26~).
|
||||
# 단 2026-06 현재 정상 생산 진입 — 데이터 누적되면(또는 최신 dump 재반입) 정상 분석 대상.
|
||||
# 현재는 생산구간만 추려 쓰되 샘플 부족에 주의(맵 R²·트리거 변동성 클 수 있음).
|
||||
|
||||
PREFIX_ASSET = {p: a for p, a, _ in COLUMNS}
|
||||
|
||||
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
def extract(prefix, asset):
|
||||
"""추출 + 운전모드 분류. c{prefix}_data.pkl 저장."""
|
||||
from c6111_extract import roles_for, tag_frame, classify_phases, clip_to_ranges
|
||||
|
||||
with psycopg.connect(DSN) as conn:
|
||||
roles = roles_for(prefix, asset)
|
||||
print(f"\n ROLES ({len(roles)}):")
|
||||
for k, v in roles.items():
|
||||
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)
|
||||
|
||||
print(f"\n=== {prefix} ({asset}) ===")
|
||||
print(f" 행수={len(df)} 기간={df.dtat.min()} ~ {df.dtat.max()}")
|
||||
vc = df["mode"].value_counts()
|
||||
for m, n in vc.items():
|
||||
print(f" {m:9s} {n:7d} {100*n/len(df):5.1f}% ≈ {n*30/3600:.1f}h")
|
||||
print(f" 저장: {out}")
|
||||
return out
|
||||
|
||||
|
||||
def run_analysis(script, prefix):
|
||||
"""분석 스크립트 1개 실행 (subprocess)."""
|
||||
data = os.path.join(BASE, f"c{prefix}_data.pkl")
|
||||
cmd = [PY, os.path.join(BASE, script), "--data", data, "--prefix", f"c{prefix}"]
|
||||
print(f"\n>>> {' '.join(cmd)}")
|
||||
r = subprocess.run(cmd)
|
||||
return r.returncode
|
||||
|
||||
|
||||
def run_column(prefix, asset, label):
|
||||
"""컬럼 1개 전체 파이프라인."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label} (prefix={prefix}, asset={asset})")
|
||||
print(f"{'='*60}")
|
||||
extract(prefix, asset)
|
||||
for script in ["c6111_prodmap.py", "c6111_shadow.py", "c6111_rolling.py", "c6111_startup.py", "c6111_shutdown.py", "c6111_operator_assist.py", "c6111_export_model.py"]:
|
||||
rc = run_analysis(script, prefix)
|
||||
if rc != 0:
|
||||
print(f" [WARN] {script} → exit {rc}")
|
||||
|
||||
|
||||
def compare():
|
||||
"""모든 컬럼 결과 취합 → 비교표 (prodmap + shadow + startup)."""
|
||||
import numpy as np
|
||||
|
||||
rows = []
|
||||
for prefix, asset, label in COLUMNS:
|
||||
pkl = os.path.join(BASE, f"c{prefix}_data.pkl")
|
||||
# 6-1 legacy: c6111_data.pkl (not c61_data.pkl)
|
||||
if prefix == "61" and not os.path.exists(pkl):
|
||||
alt = os.path.join(BASE, "c6111_data.pkl")
|
||||
if os.path.exists(alt):
|
||||
pkl = alt
|
||||
if not os.path.exists(pkl):
|
||||
print(f" [skip] {label}: {pkl} 없음")
|
||||
continue
|
||||
df = pd.read_pickle(pkl)
|
||||
prod = df[df["mode"] == "PROD"]
|
||||
steam_feed = prod["steam_flow"].median() / prod["feed"].median() if len(prod) else float("nan")
|
||||
total_h = len(df) * 30 / 3600
|
||||
prod_h = len(prod) * 30 / 3600
|
||||
|
||||
# 컷인 탐지 (startup.py detect_cutins 로직 인라인)
|
||||
prod_arr = df["product"].values
|
||||
reb_arr = df["reb_temp"].values
|
||||
dtat_vals = df["dtat"].values
|
||||
cutins = []
|
||||
i = 60
|
||||
n = len(df)
|
||||
while i < n:
|
||||
if prod_arr[i] > 100 and prod_arr[i-1] <= 100:
|
||||
pre = prod_arr[max(0, i-60):i]
|
||||
if np.nanmedian(pre) < 50 and reb_arr[i] > 75:
|
||||
cutins.append(i)
|
||||
i += 720
|
||||
continue
|
||||
i += 1
|
||||
|
||||
row = {"컬럼": label,
|
||||
"기간": f"{df['dtat'].min():%m-%d}~{df['dtat'].max():%m-%d}",
|
||||
"전체(h)": f"{total_h:.0f}",
|
||||
"PROD%": f"{100*len(prod)/len(df):.1f}",
|
||||
"생산(h)": f"{prod_h:.0f}",
|
||||
"steam/feed": f"{steam_feed:.3f}",
|
||||
"컷인": str(len(cutins))}
|
||||
|
||||
if cutins:
|
||||
cutin_data = []
|
||||
for ci in cutins:
|
||||
cutin_data.append({"reb": df.loc[ci, "reb_temp"],
|
||||
"tc": df.loc[ci, "T_C"],
|
||||
"dT": df.loc[ci, "reb_temp"] - df.loc[ci, "T_D"]})
|
||||
cdf = pd.DataFrame(cutin_data)
|
||||
row["컷인_reb-A"] = f"{cdf['reb'].median():.1f}±{cdf['reb'].std():.1f}"
|
||||
row["컷인_dT_AD"] = f"{cdf['dT'].median():.1f}±{cdf['dT'].std():.1f}"
|
||||
else:
|
||||
row["컷인_reb-A"] = ""
|
||||
row["컷인_dT_AD"] = ""
|
||||
|
||||
rows.append(row)
|
||||
|
||||
pd.set_option("display.width", 300)
|
||||
pd.set_option("display.max_columns", 20)
|
||||
print("\n\n" + "="*120)
|
||||
print(" 형제 컬럼 비교표")
|
||||
print("="*120)
|
||||
tbl = pd.DataFrame(rows).set_index("컬럼")
|
||||
print(tbl.to_string())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="형제 컬럼 확장 일괄 실행")
|
||||
parser.add_argument("--prefix", help="컬럼 prefix (61, 62, 81, 91, 101)")
|
||||
parser.add_argument("--asset", help="asset 경로 (예: /ASSETS/P6)")
|
||||
parser.add_argument("--all", action="store_true", help="모든 형제 컬럼 실행")
|
||||
parser.add_argument("--compare", action="store_true", help="기존 pkl로 비교표만 출력")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.compare:
|
||||
compare()
|
||||
elif args.all:
|
||||
for prefix, asset, label in COLUMNS:
|
||||
run_column(prefix, asset, label)
|
||||
compare()
|
||||
elif args.prefix:
|
||||
asset = args.asset or PREFIX_ASSET.get(args.prefix, f"/ASSETS/P{args.prefix[0]}")
|
||||
label = f"C-{args.prefix}11"
|
||||
for p, a, l in COLUMNS:
|
||||
if p == args.prefix:
|
||||
label = l
|
||||
break
|
||||
run_column(args.prefix, asset, label)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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<string> LevelTags { get; init; } = Array.Empty<string>();
|
||||
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);
|
||||
|
||||
20
src/Core/Application/Feedforward/FfSpTag.cs
Normal file
20
src/Core/Application/Feedforward/FfSpTag.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Hc900Crawler.Core.Application.Feedforward;
|
||||
|
||||
/// <summary>
|
||||
/// 스트림/피드의 flow 태그(예: FICQ-6118, ficq-6118.pv)에서
|
||||
/// 루프 Working Set Point 태그(.SP, register-map RW)를 파생한다.
|
||||
/// override 태그가 주어지면 그것을 우선(선택적 수동 지정).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
36
src/Core/Application/Feedforward/IFeedRampStores.cs
Normal file
36
src/Core/Application/Feedforward/IFeedRampStores.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Hc900Crawler.Core.Application.Feedforward;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum FeedRampState { Ramping, Hold, Reached, Canceled }
|
||||
|
||||
/// <summary>
|
||||
/// 작업 B: FEED 램프 실행 작업 상태. 컬럼당 1개. FeedRampExecutorService가 단계마다 갱신.
|
||||
/// LastWrittenSp = 마지막으로 컨트롤러에 쓴(또는 DryRun에서 쓸 예정이던) FEED SP.
|
||||
/// </summary>
|
||||
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<string> Warnings { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public interface IFeedRampJobStore
|
||||
{
|
||||
FeedRampJob Start(int columnId, double targetFeed, string op, bool dryRun);
|
||||
FeedRampJob? Get(int columnId);
|
||||
IReadOnlyCollection<FeedRampJob> GetAll();
|
||||
void Update(FeedRampJob job);
|
||||
bool Cancel(int columnId);
|
||||
}
|
||||
@@ -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: 감사 로그 서비스
|
||||
|
||||
21
src/Core/Application/Feedforward/IFfTrackingStore.cs
Normal file
21
src/Core/Application/Feedforward/IFfTrackingStore.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Hc900Crawler.Core.Application.Feedforward;
|
||||
|
||||
/// <summary>
|
||||
/// 측류 스트림별 "추종(ON/OFF)" 상태. ON이면 Supervisor가 매 주기 권장 WSP를 연속으로 씀.
|
||||
/// </summary>
|
||||
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<FfTrackingState> GetAll();
|
||||
}
|
||||
@@ -60,6 +60,9 @@ public interface IExperionDbService
|
||||
/// <summary>realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기</summary>
|
||||
Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames);
|
||||
|
||||
/// <summary>태그(베이스 기준)가 어느 컨트롤러(C1~C4)에 속하는지 realtime_table에서 해석. 없으면 null.</summary>
|
||||
Task<string?> GetControllerIdForTagAsync(string tagName);
|
||||
|
||||
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
|
||||
Task<string?> GetNodeIdByTagNameAsync(string tagName);
|
||||
|
||||
|
||||
@@ -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<int, CancellationTokenSource> _sessions = new();
|
||||
|
||||
public FastController(IExperionDbService db) => _db = db;
|
||||
public FastController(IExperionDbService db, IServiceScopeFactory scopeFactory)
|
||||
{ _db = db; _scopeFactory = scopeFactory; }
|
||||
|
||||
[HttpGet("sessions")]
|
||||
public async Task<IActionResult> 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<IExperionDbService>();
|
||||
// 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<IExperionDbService>();
|
||||
await db.UpdateFastSessionStatusAsync(sessionId, finalStatus);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
finally { lock (_sessions) { _sessions.Remove(sessionId); } }
|
||||
}
|
||||
|
||||
[HttpPost("{id}/stop")]
|
||||
|
||||
@@ -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<double?> 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<bool> 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<IActionResult> 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<IActionResult> 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<bool?>("Feedforward:FeedRampDryRun") ?? true;
|
||||
|
||||
[HttpPost("feed-ramp/{columnId:int}/start")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<bool>("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; }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")]
|
||||
|
||||
300
src/Hc900Crawler/Controllers/SteamAdvisorController.cs
Normal file
300
src/Hc900Crawler/Controllers/SteamAdvisorController.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
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;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/steam")]
|
||||
public sealed class SteamAdvisorController : ControllerBase
|
||||
{
|
||||
private static readonly Dictionary<string, string> _tagDescs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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()
|
||||
{
|
||||
return Ok(new { loaded = _advisor.IsLoaded });
|
||||
}
|
||||
|
||||
[HttpGet("predict")]
|
||||
public IActionResult Predict(
|
||||
[FromQuery] double feed,
|
||||
[FromQuery] double product,
|
||||
[FromQuery] double tC)
|
||||
{
|
||||
var result = _advisor.Predict(feed, product, tC);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("predict")]
|
||||
public IActionResult PredictPost([FromBody] SteamPredictBody body)
|
||||
{
|
||||
var result = _advisor.Predict(body.Feed, body.Product, body.TC);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("models")]
|
||||
public IActionResult ListModels()
|
||||
{
|
||||
var modelDir = _config.GetValue<string>("SteamAdvisor:ModelDir")
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
|
||||
if (!Directory.Exists(modelDir))
|
||||
return Ok(new { columns = Array.Empty<string>() });
|
||||
|
||||
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<IActionResult> Backtest(string col)
|
||||
{
|
||||
var plotDir = _config.GetValue<string>("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<IActionResult> Live([FromQuery] string? col = null)
|
||||
{
|
||||
col ??= _config.GetValue<string>("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<IActionResult> TempProfile(string col)
|
||||
{
|
||||
var dir = _config.GetValue<string>("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<TempRef>(
|
||||
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<string, string> TagsFor(string p)
|
||||
{
|
||||
var m = new Dictionary<string, string>
|
||||
{
|
||||
["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<string> StagesOrder { get; init; } = [];
|
||||
[JsonPropertyName("n_products")] public int NProducts { get; init; }
|
||||
public string Period { get; init; } = "";
|
||||
public List<TempProduct> 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<string, TempStat> Stages { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record TempStat
|
||||
{
|
||||
public double Median { get; init; }
|
||||
public double Std { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SteamPredictBody
|
||||
{
|
||||
public double Feed { get; init; }
|
||||
public double Product { get; init; }
|
||||
public double TC { get; init; }
|
||||
}
|
||||
@@ -103,6 +103,16 @@ builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ISimOver
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ICompositionStore,
|
||||
Hc900Crawler.Infrastructure.Control.CompositionStore>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService>();
|
||||
// 측류 추종(ON/OFF) 상태 저장소
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFfTrackingStore,
|
||||
Hc900Crawler.Infrastructure.Control.FfTrackingStore>();
|
||||
// 작업 B: FEED 램프 실행기 + 작업 저장소
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFeedRampJobStore,
|
||||
Hc900Crawler.Infrastructure.Control.FeedRampJobStore>();
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Control.FeedRampExecutorService>();
|
||||
|
||||
// ── Steam Advisor (작업4) ────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Control.SteamAdvisor>();
|
||||
|
||||
// ── MCP Service ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient(McpClient.HttpClientName, c =>
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"Enabled": true
|
||||
},
|
||||
"Feedforward": {
|
||||
"SimOverrideEnabled": true
|
||||
"SimOverrideEnabled": true,
|
||||
"FeedRampDryRun": false,
|
||||
"FeedRampStepSec": 10
|
||||
},
|
||||
"McpServer": {
|
||||
"WorkingDirectory": "../../mcp-server"
|
||||
@@ -62,6 +64,22 @@
|
||||
"LockoutMinutes": 15
|
||||
}
|
||||
},
|
||||
"SteamAdvisor": {
|
||||
"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": {
|
||||
"Http": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
<span class="ni">12</span>
|
||||
<span class="nl">유량 권장(FF)</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="steam">
|
||||
<span class="ni">13</span>
|
||||
<span class="nl">스팀 Advisory</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -103,19 +107,32 @@
|
||||
|
||||
<!-- ── Main (panes loaded dynamically via data-src) ──────── -->
|
||||
<main class="content">
|
||||
<section class="pane active" id="pane-setup" data-src="/panes/setup.html"></section>
|
||||
<section class="pane" id="pane-pb" data-src="/panes/pb.html"></section>
|
||||
<section class="pane" id="pane-hist" data-src="/panes/hist.html"></section>
|
||||
<section class="pane" id="pane-t2s" data-src="/panes/t2s.html"></section>
|
||||
<section class="pane" id="pane-fast" data-src="/panes/fast.html"></section>
|
||||
<section class="pane" id="pane-pid" data-src="/panes/pid.html"></section>
|
||||
<section class="pane" id="pane-evt" data-src="/panes/evt.html"></section>
|
||||
<section class="pane" id="pane-llmchat" data-src="/panes/llmchat.html"></section>
|
||||
<section class="pane" id="pane-kbadmin" data-src="/panes/kbadmin.html"></section>
|
||||
<section class="pane" id="pane-write" data-src="/panes/write.html"></section>
|
||||
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
|
||||
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
|
||||
<section class="pane active" id="pane-setup" data-src="/panes/setup.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-pb" data-src="/panes/pb.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-hist" data-src="/panes/hist.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-t2s" data-src="/panes/t2s.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-fast" data-src="/panes/fast.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-pid" data-src="/panes/pid.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-evt" data-src="/panes/evt.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-llmchat" data-src="/panes/llmchat.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-kbadmin" data-src="/panes/kbadmin.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-write" data-src="/panes/write.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-docs" data-src="/panes/docs.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-trend" data-src="/panes/trend.html?v=20260604"></section>
|
||||
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
|
||||
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -197,22 +214,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/lib/uPlot.iife.min.js"></script>
|
||||
<script src="/lib/echarts.min.js"></script>
|
||||
<script src="/js/xlsx.full.min.js"></script>
|
||||
<script src="/js/core.js"></script>
|
||||
<script src="/js/setup.js"></script>
|
||||
<script src="/js/pb.js"></script>
|
||||
<script src="/js/hist.js"></script>
|
||||
<script src="/js/t2s.js"></script>
|
||||
<script src="/js/fast.js"></script>
|
||||
<script src="/js/pid.js"></script>
|
||||
<script src="/js/evt.js"></script>
|
||||
<script src="/js/llmchat.js"></script>
|
||||
<script src="/js/kbadmin.js"></script>
|
||||
<script src="/js/write.js"></script>
|
||||
<script src="/js/docs.js"></script>
|
||||
<script src="/js/trend.js"></script>
|
||||
<script src="/js/ff.js"></script>
|
||||
<script src="/lib/uPlot.iife.min.js?v=20260604"></script>
|
||||
<script src="/lib/echarts.min.js?v=20260604"></script>
|
||||
<script src="/js/xlsx.full.min.js?v=20260604"></script>
|
||||
<script src="/js/core.js?v=20260604"></script>
|
||||
<script src="/js/setup.js?v=20260604"></script>
|
||||
<script src="/js/pb.js?v=20260604"></script>
|
||||
<script src="/js/hist.js?v=20260604"></script>
|
||||
<script src="/js/t2s.js?v=20260604"></script>
|
||||
<script src="/js/fast.js?v=20260604"></script>
|
||||
<script src="/js/pid.js?v=20260604"></script>
|
||||
<script src="/js/evt.js?v=20260604"></script>
|
||||
<script src="/js/llmchat.js?v=20260604"></script>
|
||||
<script src="/js/kbadmin.js?v=20260604"></script>
|
||||
<script src="/js/write.js?v=20260604"></script>
|
||||
<script src="/js/docs.js?v=20260604"></script>
|
||||
<script src="/js/trend.js?v=20260604"></script>
|
||||
<script src="/js/ff.js?v=20260604"></script>
|
||||
<script src="/js/steam.js?v=20260606"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 = `
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0"></span>
|
||||
<span style="font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(s.name)}">${esc(s.name)}</span>
|
||||
${s.pinned ? '<span style="font-size:11px">📌</span>' : ''}
|
||||
<button data-del="${s.id}" title="삭제" style="margin-left:2px;background:none;border:none;color:var(--t2);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
|
||||
<span style="font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(s.Name)}">${esc(s.Name)}</span>
|
||||
${s.Pinned ? '<span style="font-size:11px">📌</span>' : ''}
|
||||
<button data-del="${s.Id}" title="삭제" style="margin-left:2px;background:none;border:none;color:var(--t2);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--t2)">${label} · ${s.tagCount}태그 · ${s.samplingMs}ms</div>
|
||||
<div style="font-size:10px;color:var(--t3)">${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}</div>
|
||||
<div style="font-size:11px;color:var(--t2)">${label} · ${s.TagCount}태그 · ${s.SamplingMs}ms</div>
|
||||
<div style="font-size:10px;color:var(--t3)">${fastFormatDuration(s.DurationSec)} · ${fastFormatDateTime(s.StartedAt).slice(0,10)}</div>
|
||||
`;
|
||||
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]];
|
||||
|
||||
@@ -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 = '<div class="ff-empty">활성 컬럼 없음</div>'; 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 `<div class="ff-track-ctl"><span class="ff-track-on" title="권장 WSP 연속 추종 중">추종 ●</span>`
|
||||
+ `<span class="ff-track-row"><button class="btn sm" onclick="ffTrackOff(${c.columnId},'${esc(s.key)}')" title="추종 중지(현재값 유지)">OFF</button></span></div>`;
|
||||
}
|
||||
// OFF 상태 — 추종 시작 가능 조건(WriteGuard와 동일: 유효·과도아님·신뢰≠C)
|
||||
const canOn = s.recommendedSp != null && s.valid && s.grade !== 'C' && !c.transient;
|
||||
if (!canOn) return '';
|
||||
return `<div class="ff-track-ctl"><button class="btn sm ff-track-btn" onclick="ffTrackOn(${c.columnId},'${esc(s.key)}')" title="권장 WSP를 연속 추종(쓰기 시작)">추종 ON</button></div>`;
|
||||
}
|
||||
|
||||
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')
|
||||
? `<button class="btn sm" onclick="ffRampCancel(${rj.columnId})">취소</button>` : '';
|
||||
return `<div class="ff-modeline"><span class="ff-mode ${cls}">FEED 램프 ${esc(st)}${dry}</span> <small>${prog}${ceil}${hold}</small> ${cancelBtn}</div>`;
|
||||
}
|
||||
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) {
|
||||
<td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td>
|
||||
<td><span class="ff-role ff-role-${esc(s.role)}">${roleLabel}</span></td>
|
||||
<td class="ff-num">${fmtVal(s.pv)}</td>
|
||||
<td class="ff-num ff-rec">${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}</td>
|
||||
<td class="ff-num ff-rec">${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}${ffTrackCtl(c,s)}</td>
|
||||
<td class="ff-num">${s.gap==null?'–':fmtVal(s.gap)}</td>
|
||||
<td>${ffTrendIco(s.trend)}</td>
|
||||
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}${writeInfo}</td>
|
||||
@@ -236,6 +320,14 @@ function ffCard(c) {
|
||||
: '';
|
||||
const modeLine = (modeBadge || c.modeReason)
|
||||
? `<div class="ff-modeline">${modeBadge} <small>${esc(c.modeReason||'')}</small> ${recoveryCtl}</div>` : '';
|
||||
const rampJob = ffRampJobs[c.columnId];
|
||||
const rampActive = rampJob && (rampJob.state === 'Ramping' || rampJob.state === 'Hold');
|
||||
const rampLine = rampJob ? ffRampLine(rampJob) : '';
|
||||
const rampCtl = rampActive ? '' : `<div class="ff-feedramp">FEED Target SP
|
||||
<input class="inp ff-rt" data-col="${c.columnId}" type="number" step="any" value="${ffRampTargets[c.columnId] ?? ''}"
|
||||
oninput="ffRampTargets[${c.columnId}]=this.value" placeholder="목표">
|
||||
<button class="btn sm danger" onclick="ffCardRampStart(${c.columnId})" title="목표까지 FEED SP를 램프율로 점진 상승">램프 시작 ▶</button>
|
||||
<span class="ff-rt-mode">${ffRampDryRun ? '[모의]' : '[실쓰기]'}</span></div>`;
|
||||
const writeBadge = c.autoWriteActive ? '<span class="ff-write-badge">자동 SP 쓰기</span>' : '';
|
||||
const wgBlocked = c.writeGuardBlockedSp != null
|
||||
? `<div class="ff-wg-blocked">쓰기 차단: ${esc(c.writeGuardReason)} (SP <b>${fmtVal(c.writeGuardBlockedSp)}</b>)</div>`
|
||||
@@ -247,6 +339,8 @@ function ffCard(c) {
|
||||
${writeBadge}
|
||||
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
||||
${modeLine}
|
||||
${rampLine}
|
||||
${rampCtl}
|
||||
${banner}
|
||||
${wgBlocked}
|
||||
<table class="ff-tbl"><thead><tr>
|
||||
@@ -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 = `
|
||||
<div class="ff-modal-col">
|
||||
<label>컬럼명 <input class="inp" id="ff-f-name" value="${esc(def.name)}"></label>
|
||||
<label><input type="checkbox" id="ff-f-enabled" ${def.enabled?'checked':''}> 활성</label>
|
||||
<label><input type="checkbox" id="ff-f-advisoryOnly" ${def.advisoryOnly!==false?'checked':''}> AdvisoryOnly(체크=권장만, 쓰기 안 함)</label>
|
||||
<label><input type="checkbox" id="ff-f-advisoryOnly" ${def.advisoryOnly!==false?'checked':''}> AdvisoryOnly(체크=자동쓰기 안 함 · 추종 ON은 가능)</label>
|
||||
<label>Feed 태그 <input class="inp" id="ff-f-feedTag" value="${esc(def.feedTag)}"></label>
|
||||
<label>압력 태그 <input class="inp" id="ff-f-pressureTag" value="${esc(def.pressureTag)}"></label>
|
||||
<label>Product Key <input class="inp" id="ff-f-productKey" value="${esc(def.productKey)}"></label>
|
||||
<div class="ff-modal-subhd">FEED 램프 실행 <small>(작업 B)</small></div>
|
||||
<label><span class="ff-desc">(선택) FEED SP override: 비우면 Feed태그의 .SP(WSP)로 자동</span><input class="inp" id="ff-f-feedSpNodeId" value="${esc(def.feedSpNodeId||'')}" placeholder="자동(Feed.SP)"></label>
|
||||
<label><span class="ff-desc">FEED SP 하한(클램프)</span><input class="inp" type="number" step="any" id="ff-f-feedSpMin" value="${def.feedSpMin==null?0:def.feedSpMin}"></label>
|
||||
<label><span class="ff-desc">FEED SP 상한(클램프)</span><input class="inp" type="number" step="any" id="ff-f-feedSpMax" value="${def.feedSpMax==null?1e9:def.feedSpMax}"></label>
|
||||
</div>
|
||||
<div class="ff-modal-col">
|
||||
<label><span class="ff-desc">Scan(초): 계산 주기 — 빠를수록 민감하나 부하 증가</span><input class="inp" type="number" id="ff-f-scanSec" value="${def.scanSec}"></label>
|
||||
@@ -374,7 +475,7 @@ function ffEditColumn(c) {
|
||||
<table class="ff-tbl ff-stream-tbl">
|
||||
<thead><tr>
|
||||
<th>Key</th><th>Flow 태그</th><th>역할</th><th>레벨태그</th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
|
||||
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th title="전환류 시 전량환류 대상">전환류R</th><th title="전환류 시 이 스트림 권장값(비우면 0)">복귀SP</th><th title="OPC UA SP NodeId (예: ns=3;s=ficq-6113.sp)">SP NodeId</th><th>신뢰</th><th></th>
|
||||
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th title="전환류 시 전량환류 대상">전환류R</th><th title="전환류 시 이 스트림 권장값(비우면 0)">복귀SP</th><th title="(선택) SP 태그 override — 비우면 flow태그의 .SP(WSP)로 자동. 예: FICQ-6113.SP">SP override</th><th>신뢰</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody id="ff-stream-body">
|
||||
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
|
||||
@@ -431,7 +532,7 @@ function ffStreamRow(s, i) {
|
||||
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
|
||||
<td><input type="checkbox" ${s.isReflux?'checked':''} data-idx="${i}" data-f="isReflux"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.recoverySp==null?'':s.recoverySp}" data-idx="${i}" data-f="recoverySp" placeholder="0"></td>
|
||||
<td><input class="inp ff-si" value="${esc(s.spNodeId||'')}" data-idx="${i}" data-f="spNodeId" placeholder="예: ns=3;s=ficq-6113.sp"></td>
|
||||
<td><input class="inp ff-si" value="${esc(s.spNodeId||'')}" data-idx="${i}" data-f="spNodeId" placeholder="자동(flow.SP)"></td>
|
||||
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
|
||||
<td><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td>
|
||||
</tr>`;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
488
src/Hc900Crawler/wwwroot/js/steam.js
Normal file
488
src/Hc900Crawler/wwwroot/js/steam.js
Normal file
@@ -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 => `<option value="${c}">${c}</option>`).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]) => `<option value="${v}">${l}</option>`).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)}<br/>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)}<br/>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 = '<div style="padding:30px;text-align:center;color:#555">Startup 데이터 없음</div>';
|
||||
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 = '<div style="padding:30px;text-align:center;color:#555">데이터 없음</div>';
|
||||
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); }
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="ff-wrap">
|
||||
<div class="ff-head">
|
||||
<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<span class="ff-badge">권장값 · 버튼으로 인가 시 컨트롤러 SP 쓰기 — 인가는 운전원</span>
|
||||
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
|
||||
<button id="ff-ramp-toggle" class="btn">램프 계산기 ▾</button>
|
||||
</div>
|
||||
@@ -18,6 +18,8 @@
|
||||
<label>floodLimit <input class="inp" id="ff-ramp-floodLimit" type="number" step="any" value=""></label>
|
||||
<label>n(지수) <input class="inp" id="ff-ramp-n" type="number" step="any" value="1.8"></label>
|
||||
<button class="btn" id="ff-ramp-go">계산</button>
|
||||
<button class="btn danger" id="ff-ramp-start" title="목표까지 FEED SP를 단계적으로 올림">램프 시작 ▶</button>
|
||||
<span id="ff-ramp-mode" class="ff-badge"></span>
|
||||
</div>
|
||||
<div id="ff-ramp-result" class="ff-ramp-result"></div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<option value="">전체</option>
|
||||
<option value="true">활성만</option>
|
||||
<option value="false">비활성만</option>
|
||||
<option value="live">실시간</option>
|
||||
</select>
|
||||
<button class="btn-a" onclick="pbReload()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
162
src/Hc900Crawler/wwwroot/panes/steam.html
Normal file
162
src/Hc900Crawler/wwwroot/panes/steam.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<div class="steam-wrap">
|
||||
<div class="steam-head">
|
||||
<h2>Steam Advisory — 오퍼레이터 보조</h2>
|
||||
<span class="steam-badge">advisory-only · write 금지</span>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비 -->
|
||||
<div class="steam-tabs">
|
||||
<button class="steam-tab active" data-st-tab="live">라이브</button>
|
||||
<button class="steam-tab" data-st-tab="temp">온도 프로파일</button>
|
||||
<button class="steam-tab" data-st-tab="backtest">백테스트</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 라이브 패널 ═══ -->
|
||||
<div id="st-live" class="st-pane active">
|
||||
|
||||
<!-- 상태 요약 -->
|
||||
<div class="st-live-bar">
|
||||
<div>
|
||||
컬럼: <select id="st-col" class="inp" style="width:120px"></select>
|
||||
<button class="btn-a" id="st-live-load">조회</button>
|
||||
<span id="st-mode" class="st-badge st-badge-mode">—</span>
|
||||
<span id="st-conf" class="st-badge st-badge-conf">—</span>
|
||||
<span id="st-ood" class="st-badge st-badge-ood" style="display:none">OOD</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="st-live-val">OP: <strong id="st-op-rec">—</strong>% 권장</span>
|
||||
<span class="st-live-sep">|</span>
|
||||
<span class="st-live-val">실제 OP: <strong id="st-op-act">—</strong>% <small class="st-tag-name" id="st-op-act-tag"></small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권장 OP 게이지 -->
|
||||
<div class="st-gauge-wrap">
|
||||
<div class="st-gauge">
|
||||
<div class="st-gauge-track">
|
||||
<div class="st-gauge-fill" id="st-gauge-fill" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="st-gauge-ticks">
|
||||
<span>0%</span><span>25%</span><span>50%</span><span>75%</span><span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- uPlot OP 추이 -->
|
||||
<div class="st-chart-hdr">OP 추이 <small>최근 30분</small></div>
|
||||
<div id="st-op-chart" style="height:240px"></div>
|
||||
|
||||
<!-- 입력값 요약 -->
|
||||
<div class="st-inputs">
|
||||
<div class="st-input-item"><label>Feed</label><span id="st-live-feed">—</span><div class="st-tag-name" id="st-live-feed-tag"></div></div>
|
||||
<div class="st-input-item"><label>Product</label><span id="st-live-product">—</span><div class="st-tag-name" id="st-live-product-tag"></div></div>
|
||||
<div class="st-input-item"><label>T_C</label><span id="st-live-tc">—</span><div class="st-tag-name" id="st-live-tc-tag"></div></div>
|
||||
<div class="st-input-item"><label>Steam Flow</label><span id="st-live-sf">—</span><div class="st-tag-name" id="st-live-sf-tag"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 -->
|
||||
<div id="st-live-msg" class="st-live-msg"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 온도 프로파일 이격 모니터 ═══ -->
|
||||
<div id="st-temp" class="st-pane">
|
||||
<div class="st-bt-bar">
|
||||
컬럼: <select id="st-temp-col" class="inp" style="width:120px"></select>
|
||||
<button class="btn-a" id="st-temp-load">조회</button>
|
||||
<span id="st-temp-product" class="st-badge st-badge-mode">제품 —</span>
|
||||
<span id="st-temp-vac" class="st-badge st-badge-conf">진공 —</span>
|
||||
<span id="st-temp-status" style="margin-left:8px;font-size:11px;color:#888"></span>
|
||||
</div>
|
||||
<div class="st-temp-note">단별 온도(reb-A>T_B>T_C>T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)</div>
|
||||
<div id="st-chart-temp" style="height:360px"></div>
|
||||
<div class="st-bt-meta" id="st-temp-meta" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 백테스트 패널 ═══ -->
|
||||
<div id="st-backtest" class="st-pane">
|
||||
<div class="st-bt-bar">
|
||||
컬럼:
|
||||
<select id="st-bt-col" class="inp" style="width:120px"></select>
|
||||
<button class="btn-a" id="st-bt-load">조회</button>
|
||||
<span id="st-bt-status" style="margin-left:8px;font-size:11px;color:#888"></span>
|
||||
</div>
|
||||
|
||||
<!-- 백테스트 서브탭 -->
|
||||
<div class="st-bt-tabs">
|
||||
<button class="st-bt-tab active" data-st-bt="prodmap">Production Map</button>
|
||||
<button class="st-bt-tab" data-st-bt="startup">Startup</button>
|
||||
<button class="st-bt-tab" data-st-bt="shadow">Shadow</button>
|
||||
<button class="st-bt-tab" data-st-bt="advisory">Advisory</button>
|
||||
</div>
|
||||
|
||||
<div id="st-bt-prodmap" class="st-bt-pane active">
|
||||
<div class="st-bt-grid">
|
||||
<div id="st-chart-valve" class="st-chart-panel" style="height:280px"></div>
|
||||
<div id="st-chart-op-points" class="st-chart-panel" style="height:280px"></div>
|
||||
<div id="st-chart-pred-vs-act" class="st-chart-panel" style="height:280px"></div>
|
||||
<div id="st-chart-feat-imp" class="st-chart-panel" style="height:280px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="st-bt-startup" class="st-bt-pane">
|
||||
<div id="st-chart-startup" style="height:400px"></div>
|
||||
<div class="st-bt-meta" id="st-startup-meta" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div id="st-bt-shadow" class="st-bt-pane">
|
||||
<div id="st-chart-shadow" style="height:300px"></div>
|
||||
<div id="st-chart-shadow-hist" style="height:180px"></div>
|
||||
<div class="st-bt-meta" id="st-shadow-meta" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div id="st-bt-advisory" class="st-bt-pane">
|
||||
<div id="st-chart-advisory" style="height:300px"></div>
|
||||
<div id="st-chart-advisory-hist" style="height:180px"></div>
|
||||
<div class="st-bt-meta" id="st-advisory-meta" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.steam-wrap { padding:16px; display:flex; flex-direction:column; gap:10px; color:#ccc; }
|
||||
.steam-head { display:flex; align-items:center; gap:12px; }
|
||||
.steam-head h2 { margin:0; font-size:17px; }
|
||||
.steam-badge { font-size:10px; background:#2a2a1a; color:#cc6; padding:2px 8px; border-radius:3px; }
|
||||
.steam-tabs { display:flex; gap:4px; }
|
||||
.steam-tab { background:#1e2830; border:1px solid #2a3a4a; color:#888; padding:6px 16px; cursor:pointer; font-size:12px; border-radius:4px 4px 0 0; }
|
||||
.steam-tab.active { background:#0a121a; color:#4af; border-bottom-color:#0a121a; }
|
||||
.st-pane { display:none; background:#0a121a; border:1px solid #1e2a3a; border-radius:0 4px 4px; padding:14px; }
|
||||
.st-pane.active { display:block; }
|
||||
.st-live-bar { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; flex-wrap:wrap; gap:6px; }
|
||||
.st-badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:10px; font-weight:bold; margin-left:4px; }
|
||||
.st-badge-mode { background:#1a2a1a; color:#6d6; }
|
||||
.st-badge-conf { background:#1a1a3a; color:#66f; }
|
||||
.st-badge-ood { background:#3a1a1a; color:#f66; }
|
||||
.st-live-val { font-size:12px; }
|
||||
.st-live-val strong { color:#4af; font-size:15px; }
|
||||
.st-live-sep { color:#333; margin:0 6px; }
|
||||
.st-gauge-wrap { margin:8px 0; }
|
||||
.st-gauge { max-width:400px; }
|
||||
.st-gauge-track { height:18px; background:#1a1a2a; border-radius:9px; overflow:hidden; border:1px solid #2a3a4a; }
|
||||
.st-gauge-fill { height:100%; background:linear-gradient(90deg,#4a4,#afa); border-radius:9px; transition:width .5s; }
|
||||
.st-gauge-ticks { display:flex; justify-content:space-between; font-size:9px; color:#555; margin-top:2px; }
|
||||
.st-chart-hdr { font-size:12px; color:#888; margin-top:10px; margin-bottom:2px; }
|
||||
.st-inputs { display:flex; gap:14px; margin-top:8px; flex-wrap:wrap; }
|
||||
.st-input-item { background:#111820; border:1px solid #1a2a3a; border-radius:4px; padding:6px 10px; text-align:center; min-width:80px; }
|
||||
.st-input-item label { display:block; font-size:9px; color:#555; }
|
||||
.st-input-item span { font-size:13px; color:#8cf; font-weight:bold; }
|
||||
.st-tag-name { font-size:9px; color:#555; margin-top:2px; white-space:nowrap; }
|
||||
.st-live-msg { margin-top:6px; padding:4px 8px; border-radius:3px; font-size:11px; background:#1a1a1a; min-height:16px; }
|
||||
|
||||
.st-bt-bar { display:flex; align-items:center; gap:6px; margin-bottom:10px; }
|
||||
.st-bt-tabs { display:flex; gap:2px; margin-bottom:8px; }
|
||||
.st-bt-tab { background:#111820; border:1px solid #1a2a3a; color:#666; padding:4px 12px; cursor:pointer; font-size:11px; border-radius:3px; }
|
||||
.st-bt-tab.active { background:#0a1520; color:#4af; border-color:#2a4a6a; }
|
||||
.st-bt-pane { display:none; }
|
||||
.st-bt-pane.active { display:block; }
|
||||
.st-bt-grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.st-chart-panel { min-height:200px; }
|
||||
.st-bt-meta { margin-top:6px; font-size:11px; color:#666; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; padding:8px; white-space:pre-wrap; }
|
||||
.st-temp-note { font-size:11px; color:#789; margin-bottom:6px; }
|
||||
</style>
|
||||
|
||||
@@ -33,8 +33,11 @@ public sealed class FeedRampAdvisorService
|
||||
var tags = new List<string> { 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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
264
src/Infrastructure/Control/FeedRampExecutorService.cs
Normal file
264
src/Infrastructure/Control/FeedRampExecutorService.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 작업 B: FEED 램프 실행기. 운전원이 시작한 활성 Job에 대해 주기마다
|
||||
/// FeedRampCalculator 결과(RampRate·Ceiling)에 따라 FEED SP를 단계적으로 써 나간다.
|
||||
/// 안전: 피드 불량 HOLD / 범위 클램프 / 단조진행(다운 점프 금지) / DryRun / SimOverride 억제.
|
||||
/// </summary>
|
||||
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<FeedRampExecutorService> _logger;
|
||||
|
||||
private const double Eps = 1e-6;
|
||||
|
||||
public FeedRampExecutorService(
|
||||
IServiceScopeFactory scopeFactory, IFeedRampJobStore jobs,
|
||||
Hc900WriteService writeClient, ISimOverrideStore sim,
|
||||
IConfiguration appConfig, ILogger<FeedRampExecutorService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory; _jobs = jobs; _writeClient = writeClient;
|
||||
_sim = sim; _appConfig = appConfig; _logger = logger;
|
||||
}
|
||||
|
||||
private int StepIntervalSec => Math.Clamp(_appConfig.GetValue<int?>("Feedforward:FeedRampStepSec") ?? 10, 2, 120);
|
||||
private bool GlobalDryRun => _appConfig.GetValue<bool?>("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<IFeedforwardConfigStore>();
|
||||
var ramp = scope.ServiceProvider.GetRequiredService<FeedRampAdvisorService>();
|
||||
var audit = scope.ServiceProvider.GetService<IFeedforwardAuditService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
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<double?> 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;
|
||||
}
|
||||
}
|
||||
38
src/Infrastructure/Control/FeedRampJobStore.cs
Normal file
38
src/Infrastructure/Control/FeedRampJobStore.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Hc900Crawler.Core.Application.Feedforward;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Control;
|
||||
|
||||
/// <summary>작업 B: 컬럼별 활성 FEED 램프 작업 인메모리 저장소(싱글턴).</summary>
|
||||
public sealed class FeedRampJobStore : IFeedRampJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, FeedRampJob> _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<FeedRampJob> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<StreamConfig>());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, StreamState> 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)
|
||||
{
|
||||
|
||||
@@ -29,10 +29,12 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard,
|
||||
ILogger<FeedforwardSupervisor> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
src/Infrastructure/Control/FfTrackingStore.cs
Normal file
20
src/Infrastructure/Control/FfTrackingStore.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Hc900Crawler.Core.Application.Feedforward;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Control;
|
||||
|
||||
/// <summary>측류 스트림별 추종 상태 인메모리 저장소(싱글턴).</summary>
|
||||
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<FfTrackingState> GetAll() => _states.Values.ToArray();
|
||||
}
|
||||
150
src/Infrastructure/Control/SteamAdvisor.cs
Normal file
150
src/Infrastructure/Control/SteamAdvisor.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Control;
|
||||
|
||||
public sealed record SteamModel
|
||||
{
|
||||
public string Column { get; init; } = "";
|
||||
public List<string> Features { get; init; } = [];
|
||||
public List<double> LinearCoeffs { get; init; } = [];
|
||||
public double Intercept { get; init; }
|
||||
public double LinearR2 { get; init; }
|
||||
public double? GbmR2 { get; init; }
|
||||
public List<double> ValvePoly { get; init; } = []; // c3, c2, c1, c0
|
||||
public Dictionary<string, double> EnvelopeLo { get; init; } = [];
|
||||
public Dictionary<string, double> EnvelopeHi { get; init; } = [];
|
||||
public int NOperatingPoints { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SteamAdvisoryResult
|
||||
{
|
||||
public double? RecOp { get; init; }
|
||||
public double? RecSteam { get; init; }
|
||||
public string Confidence { get; init; } = "N/A";
|
||||
public string Mode { get; init; } = "UNKNOWN";
|
||||
public bool Ood { get; init; }
|
||||
public bool InEnv { get; init; }
|
||||
public double Feed { get; init; }
|
||||
public double Product { get; init; }
|
||||
public double TC { get; init; }
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed class SteamAdvisor
|
||||
{
|
||||
private SteamModel? _model;
|
||||
private readonly string _modelPath;
|
||||
private readonly ILogger<SteamAdvisor> _logger;
|
||||
|
||||
public SteamAdvisor(IConfiguration config, ILogger<SteamAdvisor> logger)
|
||||
{
|
||||
_modelPath = config.GetValue<string>("SteamAdvisor:ModelPath")
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json";
|
||||
_logger = logger;
|
||||
LoadModel();
|
||||
}
|
||||
|
||||
public bool IsLoaded => _model is not null;
|
||||
|
||||
public void LoadModel(string? path = null)
|
||||
{
|
||||
var p = path ?? _modelPath;
|
||||
if (!File.Exists(p))
|
||||
{
|
||||
_logger.LogWarning("[SteamAdvisor] 모델 파일 없음: {Path}", p);
|
||||
return;
|
||||
}
|
||||
var json = File.ReadAllText(p);
|
||||
_model = JsonSerializer.Deserialize<SteamModel>(json);
|
||||
_logger.LogInformation("[SteamAdvisor] 모델 로드: {Column} (R²={R2})",
|
||||
_model?.Column, _model?.LinearR2);
|
||||
}
|
||||
|
||||
public SteamAdvisoryResult Predict(double feed, double product, double tC)
|
||||
{
|
||||
if (_model is null)
|
||||
return new SteamAdvisoryResult { Message = "모델 미로드", Confidence = "N/A", Mode = "UNKNOWN",
|
||||
Feed = feed, Product = product, TC = tC };
|
||||
if (double.IsNaN(feed) || double.IsNaN(product) || double.IsNaN(tC))
|
||||
return new SteamAdvisoryResult { Message = "입력값에 NaN 포함", Confidence = "N/A",
|
||||
Mode = "INVALID", Feed = feed, Product = product, TC = tC };
|
||||
|
||||
var mode = ClassifyMode(feed, product, tC);
|
||||
var inEnv = InEnvelope(feed, product, tC, _model);
|
||||
var steam = _model.Intercept
|
||||
+ _model.LinearCoeffs[0] * feed
|
||||
+ _model.LinearCoeffs[1] * product
|
||||
+ _model.LinearCoeffs[2] * tC;
|
||||
var op = PolyVal(_model.ValvePoly, steam);
|
||||
op = Math.Clamp(op, 0, 100);
|
||||
|
||||
if (mode != "PROD")
|
||||
{
|
||||
return new SteamAdvisoryResult
|
||||
{
|
||||
RecOp = null, RecSteam = null, Confidence = "N/A",
|
||||
Mode = mode, Ood = !inEnv, InEnv = inEnv,
|
||||
Feed = feed, Product = product, TC = tC,
|
||||
Message = $"운전모드={mode} — advisory는 PROD에서만 제공"
|
||||
};
|
||||
}
|
||||
|
||||
string confidence;
|
||||
string msg;
|
||||
if (!inEnv)
|
||||
{
|
||||
confidence = "LOW_OOD";
|
||||
msg = $"⚠ 범위밖 입력 — 권장 OP={op:F1}% (외삽, 신뢰도 낮음). 오퍼레이터 판단 우선";
|
||||
}
|
||||
else
|
||||
{
|
||||
confidence = "HIGH";
|
||||
msg = $"권장 OP={op:F1}% (신뢰: 구간내)";
|
||||
}
|
||||
|
||||
return new SteamAdvisoryResult
|
||||
{
|
||||
RecOp = Math.Round(op, 1), RecSteam = Math.Round(steam, 1),
|
||||
Confidence = confidence, Mode = mode, Ood = !inEnv, InEnv = inEnv,
|
||||
Feed = feed, Product = product, TC = tC, Message = msg
|
||||
};
|
||||
}
|
||||
|
||||
public ValueTask<SteamAdvisoryResult> PredictAsync(double feed, double product, double tC, CancellationToken ct = default)
|
||||
{
|
||||
_ = ct;
|
||||
return ValueTask.FromResult(Predict(feed, product, tC));
|
||||
}
|
||||
|
||||
private static string ClassifyMode(double feed, double product, double tC)
|
||||
{
|
||||
if (product > 100) return "PROD";
|
||||
if (tC > 60) return "LINEOUT";
|
||||
if (feed > 50) return "PROD";
|
||||
return "STOPPED";
|
||||
}
|
||||
|
||||
private static bool InEnvelope(double feed, double product, double tC, SteamModel m)
|
||||
{
|
||||
if (m.EnvelopeLo.Count == 0 || m.EnvelopeHi.Count == 0) return true;
|
||||
if (feed < m.EnvelopeLo.GetValueOrDefault("feed", double.MinValue)) return false;
|
||||
if (feed > m.EnvelopeHi.GetValueOrDefault("feed", double.MaxValue)) return false;
|
||||
if (product < m.EnvelopeLo.GetValueOrDefault("product", double.MinValue)) return false;
|
||||
if (product > m.EnvelopeHi.GetValueOrDefault("product", double.MaxValue)) return false;
|
||||
if (tC < m.EnvelopeLo.GetValueOrDefault("T_C", double.MinValue)) return false;
|
||||
if (tC > m.EnvelopeHi.GetValueOrDefault("T_C", double.MaxValue)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private double PolyVal(List<double> coeffs, double x)
|
||||
{
|
||||
if (coeffs.Count < 4)
|
||||
{
|
||||
_logger.LogWarning("[SteamAdvisor] ValvePoly 계수 부족 ({Count}개, 4 필요)", coeffs.Count);
|
||||
return x;
|
||||
}
|
||||
return coeffs[0] * x * x * x + coeffs[1] * x * x + coeffs[2] * x + coeffs[3];
|
||||
}
|
||||
}
|
||||
@@ -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<string?> 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<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames)
|
||||
{
|
||||
try
|
||||
@@ -1774,8 +1806,9 @@ public class Hc900DbService : IExperionDbService
|
||||
var tags = tagNames.ToList();
|
||||
if (tags.Count == 0) return Enumerable.Empty<RealtimePoint>();
|
||||
|
||||
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<string, string>? _tagNameMap;
|
||||
|
||||
private async Task<Dictionary<string, string>> GetTagNameMapAsync()
|
||||
{
|
||||
if (_tagNameMap != null) return _tagNameMap;
|
||||
|
||||
var map = new Dictionary<string, string>(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<string, string> 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<IEnumerable<EventHistoryRow>> 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,
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user