From 34ad1db0e3c337921e11da158b2bca0394062571 Mon Sep 17 00:00:00 2001 From: backuppc Date: Thu, 4 Dec 2025 14:43:57 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EC=83=81=ED=83=9C=EB=A8=B8=EC=8B=A0=20?= =?UTF-8?q?=EB=A3=A8=ED=94=84=20=EB=B8=94=EB=A1=9C=ED=82=B9=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20-=20SPS=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=B9=84=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=B3=B4=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sm_SPS 이벤트 핸들러에서 장치 연결 및 상태 전송을 비동기로 처리 - DeviceConnectionWorker 스레드로 장치 연결 분리 - SPS(1초), Running(2초) 타임아웃 보호 추가 - 상태머신 모니터링 디버그 창 추가 (fStateMachineDebug) - F11/F12 단축키로 스레드 덤프 및 디버그 브레이크 지원 - RaiseMessage 이벤트 비동기 처리로 로그 블로킹 방지 --- Cs_HMI/Project/AGV4.csproj | 6 + .../Dialog/fStateMachineDebug.Designer.cs | 46 + Cs_HMI/Project/Dialog/fStateMachineDebug.cs | 237 +++++ Cs_HMI/Project/StateMachine/_SPS.cs | 88 +- Cs_HMI/Project/fMain.cs | 136 ++- Cs_HMI/StateMachine/EventArgs.cs | 22 +- Cs_HMI/StateMachine/StateMachine.cs | 119 ++- agv_log_report_v1.py | 948 ++++++++++++++++++ 8 files changed, 1557 insertions(+), 45 deletions(-) create mode 100644 Cs_HMI/Project/Dialog/fStateMachineDebug.Designer.cs create mode 100644 Cs_HMI/Project/Dialog/fStateMachineDebug.cs create mode 100644 agv_log_report_v1.py diff --git a/Cs_HMI/Project/AGV4.csproj b/Cs_HMI/Project/AGV4.csproj index e942ed8..212b142 100644 --- a/Cs_HMI/Project/AGV4.csproj +++ b/Cs_HMI/Project/AGV4.csproj @@ -193,6 +193,12 @@ fCounter.cs + + Form + + + fStateMachineDebug.cs + Form diff --git a/Cs_HMI/Project/Dialog/fStateMachineDebug.Designer.cs b/Cs_HMI/Project/Dialog/fStateMachineDebug.Designer.cs new file mode 100644 index 0000000..24817c6 --- /dev/null +++ b/Cs_HMI/Project/Dialog/fStateMachineDebug.Designer.cs @@ -0,0 +1,46 @@ +namespace Project.Dialog +{ + partial class fStateMachineDebug + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // fStateMachineDebug + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(800, 600); + this.Name = "fStateMachineDebug"; + this.Text = "상태머신 디버그"; + this.ResumeLayout(false); + + } + + #endregion + } +} diff --git a/Cs_HMI/Project/Dialog/fStateMachineDebug.cs b/Cs_HMI/Project/Dialog/fStateMachineDebug.cs new file mode 100644 index 0000000..f2b3965 --- /dev/null +++ b/Cs_HMI/Project/Dialog/fStateMachineDebug.cs @@ -0,0 +1,237 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using System.Threading; +using System.Diagnostics; +using AR; +using COMM; + +namespace Project.Dialog +{ + public partial class fStateMachineDebug : Form + { + private System.Windows.Forms.Timer updateTimer; + private TextBox txtDebugInfo; + private Button btnRefresh; + private Button btnForceRestart; + + public fStateMachineDebug() + { + InitializeComponent(); + InitializeCustomComponents(); + + updateTimer = new System.Windows.Forms.Timer(); + updateTimer.Interval = 500; // 0.5초마다 업데이트 + updateTimer.Tick += UpdateTimer_Tick; + updateTimer.Start(); + } + + private void InitializeCustomComponents() + { + this.Text = "상태머신 디버그 모니터"; + this.Size = new Size(800, 600); + this.StartPosition = FormStartPosition.CenterScreen; + this.FormBorderStyle = FormBorderStyle.Sizable; + + // TextBox 생성 + txtDebugInfo = new TextBox + { + Multiline = true, + ScrollBars = ScrollBars.Both, + Dock = DockStyle.Fill, + Font = new Font("Consolas", 9), + ReadOnly = true, + BackColor = Color.Black, + ForeColor = Color.LimeGreen + }; + + // 버튼 패널 + var buttonPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 50 + }; + + btnRefresh = new Button + { + Text = "새로고침", + Location = new Point(10, 10), + Size = new Size(100, 30) + }; + btnRefresh.Click += (s, e) => UpdateDebugInfo(); + + btnForceRestart = new Button + { + Text = "상태머신 재시작", + Location = new Point(120, 10), + Size = new Size(120, 30), + BackColor = Color.IndianRed + }; + btnForceRestart.Click += BtnForceRestart_Click; + + var btnBreakNow = new Button + { + Text = "즉시 중단", + Location = new Point(250, 10), + Size = new Size(100, 30), + BackColor = Color.Orange + }; + btnBreakNow.Click += (s, ev) => { + if (System.Diagnostics.Debugger.IsAttached) + { + System.Diagnostics.Debugger.Break(); + } + else + { + MessageBox.Show("디버거가 연결되지 않았습니다.", "알림"); + } + }; + + buttonPanel.Controls.Add(btnRefresh); + buttonPanel.Controls.Add(btnForceRestart); + buttonPanel.Controls.Add(btnBreakNow); + + this.Controls.Add(txtDebugInfo); + this.Controls.Add(buttonPanel); + } + + private void UpdateTimer_Tick(object sender, EventArgs e) + { + UpdateDebugInfo(); + } + + private void UpdateDebugInfo() + { + try + { + var info = new System.Text.StringBuilder(); + info.AppendLine("=== 상태머신 디버그 정보 ==="); + info.AppendLine($"현재 시간: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}"); + info.AppendLine(); + + if (PUB.sm != null) + { + info.AppendLine($"상태머신 객체: 존재"); + info.AppendLine($"IsThreadRun: {PUB.sm.IsThreadRun}"); + info.AppendLine($"LoopCount: {PUB.sm.LoopCount}"); + + var lastLoopElapsed = (DateTime.Now - PUB.sm.LastLoopTime).TotalSeconds; + var loopStatus = lastLoopElapsed < 1 ? "정상" : "경고!"; + var loopColor = lastLoopElapsed < 1 ? "" : " <<<<<<"; + info.AppendLine($"마지막 루프: {lastLoopElapsed:F2}초 전 [{loopStatus}]{loopColor}"); + + info.AppendLine($"현재 Step: {PUB.sm.Step}"); + info.AppendLine($"현재 RunStep: {PUB.sm.RunStep}"); + info.AppendLine($"Pause 상태: {PUB.sm.bPause}"); + info.AppendLine($"WaitFirstRun: {PUB.sm.WaitFirstRun}"); + + // 스레드 정보 + var smThread = GetStateMachineThread(); + if (smThread != null) + { + info.AppendLine(); + info.AppendLine($"스레드 이름: {smThread.Name ?? "N/A"}"); + info.AppendLine($"스레드 ID: {smThread.ManagedThreadId}"); + info.AppendLine($"스레드 상태: {smThread.ThreadState}"); + info.AppendLine($"IsAlive: {smThread.IsAlive}"); + info.AppendLine($"IsBackground: {smThread.IsBackground}"); + } + else + { + info.AppendLine(); + info.AppendLine("경고: 상태머신 스레드를 찾을 수 없음!"); + } + } + else + { + info.AppendLine("오류: 상태머신 객체가 NULL입니다!"); + } + + info.AppendLine(); + info.AppendLine("=== 변수 상태 ==="); + info.AppendLine($"FLAG_AUTORUN: {VAR.BOOL[eVarBool.FLAG_AUTORUN]}"); + info.AppendLine($"EMERGENCY: {VAR.BOOL[eVarBool.EMERGENCY]}"); + info.AppendLine($"FLAG_SYNC: {VAR.BOOL[eVarBool.FLAG_SYNC]}"); + + info.AppendLine(); + info.AppendLine("=== 하드웨어 연결 상태 ==="); + info.AppendLine($"AGV: {(PUB.AGV?.IsOpen ?? false)} - {PUB.setting.Port_AGV}"); + info.AppendLine($"XBE: {(PUB.XBE?.IsOpen ?? false)} - {PUB.setting.Port_XBE}"); + info.AppendLine($"BMS: {(PUB.BMS?.IsOpen ?? false)} - {PUB.setting.Port_BAT}"); + + info.AppendLine(); + info.AppendLine("=== 관리되는 스레드 목록 ==="); + var currentProcess = Process.GetCurrentProcess(); + info.AppendLine($"총 프로세스 스레드 수: {currentProcess.Threads.Count}"); + + // 모든 관리되는 스레드 정보 출력 + foreach (ProcessThread thread in currentProcess.Threads) + { + var state = thread.ThreadState; + var waitReason = thread.ThreadState == System.Diagnostics.ThreadState.Wait ? + $", WaitReason: {thread.WaitReason}" : ""; + info.AppendLine($" Thread {thread.Id}: State={state}, Priority={thread.PriorityLevel}{waitReason}"); + } + + txtDebugInfo.Text = info.ToString(); + } + catch (Exception ex) + { + txtDebugInfo.Text = $"오류 발생: {ex.Message}\r\n{ex.StackTrace}"; + } + } + + private Thread GetStateMachineThread() + { + try + { + // Reflection을 사용하여 StateMachine의 private worker 필드 접근 + var smType = PUB.sm.GetType(); + var threadField = smType.GetField("worker", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + if (threadField != null) + { + return threadField.GetValue(PUB.sm) as Thread; + } + } + catch { } + return null; + } + + private void BtnForceRestart_Click(object sender, EventArgs e) + { + var result = MessageBox.Show( + "상태머신을 강제로 재시작하시겠습니까?\n이 작업은 위험할 수 있습니다.", + "경고", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + + if (result == DialogResult.Yes) + { + try + { + PUB.log.Add("StateMachine", "사용자가 강제 재시작 요청"); + PUB.sm.Stop(); + System.Threading.Thread.Sleep(1000); + PUB.sm.Start(); + PUB.log.Add("StateMachine", "강제 재시작 완료"); + MessageBox.Show("상태머신이 재시작되었습니다.", "완료"); + } + catch (Exception ex) + { + MessageBox.Show($"재시작 실패: {ex.Message}", "오류"); + PUB.log.AddE($"상태머신 재시작 실패: {ex.Message}"); + } + } + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + updateTimer?.Stop(); + updateTimer?.Dispose(); + base.OnFormClosing(e); + } + } +} diff --git a/Cs_HMI/Project/StateMachine/_SPS.cs b/Cs_HMI/Project/StateMachine/_SPS.cs index 310746b..adacc9e 100644 --- a/Cs_HMI/Project/StateMachine/_SPS.cs +++ b/Cs_HMI/Project/StateMachine/_SPS.cs @@ -38,12 +38,12 @@ namespace Project dev.BaudRate = baud; if (dev.Open()) { - PUB.log.Add(port, "연결완료"); + PUB.log.Add(port, $"[AGV:{port}:{baud}]연결완료"); } else { var errmessage = dev.errorMessage; - PUB.log.Add("ERROR-" + port, errmessage); + PUB.log.AddE($"[AGV:{port}:{baud}] {errmessage}"); } VAR.TIME.Update(conn); VAR.TIME.Update(conntry); @@ -76,12 +76,12 @@ namespace Project dev.BaudRate = baud; if (dev.Open()) { - PUB.log.Add(port, "연결완료"); + PUB.log.Add(port, $"[XBEE:{port}:{baud}]연결완료"); } else { var errmessage = dev.errorMessage; - PUB.log.Add("ERROR-" + port, errmessage); + PUB.log.AddE($"[XBEE:{port}:{baud}] {errmessage}"); } VAR.TIME.Update(conn); VAR.TIME.Update(conntry); @@ -135,6 +135,7 @@ namespace Project // 장치 연결 처리 워커 (별도 쓰레드에서 실행) private void DeviceConnectionWorker() { + PUB.log.Add($"Start DeviceConnectionWorker"); while (isDeviceConnectionRunning) { try @@ -155,9 +156,10 @@ namespace Project var ts = VAR.TIME.RUN(eVarTime.LastConn_BAT); if (ts.TotalSeconds > 3) { - Console.WriteLine($"bms connect to {PUB.setting.Port_BAT}"); + PUB.log.Add($"bms connect to {PUB.setting.Port_BAT}"); PUB.BMS.PortName = PUB.setting.Port_BAT; - PUB.BMS.Open(); + if (PUB.BMS.Open()) + PUB.log.AddI($"BMS Connected({PUB.setting.Port_BAT})"); VAR.TIME.Update(eVarTime.LastConn_BAT); VAR.TIME.Update(eVarTime.LastConnTry_BAT); @@ -166,7 +168,7 @@ namespace Project else if (PUB.BMS.IsValid == false) { var ts = VAR.TIME.RUN(eVarTime.LastConnTry_BAT); - if (ts.TotalSeconds > 10) + if (ts.TotalSeconds > (PUB.setting.interval_bms * 2.5)) { Console.WriteLine("bms auto disconnect"); PUB.BMS.Close(); @@ -188,35 +190,61 @@ namespace Project void sm_SPS(object sender, EventArgs e) { if (PUB.sm.Step < eSMStep.IDLE || PUB.sm.Step >= eSMStep.CLOSING) return; + //return; - // 장치 연결 쓰레드가 실행 중이 아니면 시작 - if (!isDeviceConnectionRunning) + try { - StartDeviceConnectionThread(); - } - - //지그비상태전송 - if (PUB.XBE != null && PUB.XBE.IsOpen) - { - //일정간격으로 상태를 전송한다 - if (PUB.XBE.LastStatusSendTime.Year == 1982) PUB.XBE.LastStatusSendTime = DateTime.Now.AddSeconds(1); - var ts = DateTime.Now - PUB.XBE.LastStatusSendTime; - if (ts.TotalSeconds >= PUB.setting.interval_xbe) + // 장치 연결 쓰레드가 실행 중이 아니면 시작 (한 번만) + if (!isDeviceConnectionRunning) { - PUB.XBE.SendStatus(); + StartDeviceConnectionThread(); + } + + //지그비상태전송 (비동기로 실행) + if (PUB.XBE != null && PUB.XBE.IsOpen) + { + //일정간격으로 상태를 전송한다 + if (PUB.XBE.LastStatusSendTime.Year == 1982) PUB.XBE.LastStatusSendTime = DateTime.Now.AddSeconds(1); + var ts = DateTime.Now - PUB.XBE.LastStatusSendTime; + if (ts.TotalSeconds >= PUB.setting.interval_xbe) + { + + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { PUB.XBE.SendStatus(); } + catch { /* 무시 */ } + finally { PUB.XBE.LastStatusSendTime = DateTime.Now; } + }); + } + } + + //배터리쿼리 (비동기로 실행) + if (PUB.BMS != null && PUB.BMS.IsOpen) + { + if (PUB.BMS.lastSendTime.Year == 1982) PUB.BMS.lastSendTime = DateTime.Now.AddSeconds(1); + var ts = DateTime.Now - PUB.BMS.lastSendTime; + if (ts.TotalSeconds >= PUB.setting.interval_bms) + { + //PUB.BMS.lastSendTime = DateTime.Now; + //System.Threading.ThreadPool.QueueUserWorkItem(_ => + //{ + // try { PUB.BMS.SendQuery(); } + // catch { /* 무시 */ } + // finally { PUB.BMS.lastSendTime = DateTime.Now; } + //}); + } + + // Update_BatteryWarnSpeak도 비동기로 + //System.Threading.ThreadPool.QueueUserWorkItem(_ => + //{ + try { Update_BatteryWarnSpeak(); } + catch { /* 무시 */ } + //}); } } - - //배터리쿼리 - if (PUB.BMS != null && PUB.BMS.IsOpen) + catch (Exception ex) { - if (PUB.BMS.lastSendTime.Year == 1982) PUB.BMS.lastSendTime = DateTime.Now.AddSeconds(1); - var ts = DateTime.Now - PUB.BMS.lastSendTime; - if (ts.TotalSeconds >= PUB.setting.interval_bms) - { - PUB.BMS.SendQuery(); - } - Update_BatteryWarnSpeak(); + PUB.log.AddE($"sm_SPS Exception: {ex.Message}"); } } } diff --git a/Cs_HMI/Project/fMain.cs b/Cs_HMI/Project/fMain.cs index 70e3be9..53bd424 100644 --- a/Cs_HMI/Project/fMain.cs +++ b/Cs_HMI/Project/fMain.cs @@ -48,6 +48,26 @@ namespace Project } else if (e1.KeyCode == Keys.F5) btAutoRun.PerformClick(); else if (e1.KeyCode == Keys.F9) btAutoRun.PerformClick(); + else if (e1.KeyCode == Keys.F11 && System.Diagnostics.Debugger.IsAttached) + { + // F11: 모든 스레드 상태 덤프 + DumpAllThreadsState(); + } + else if (e1.KeyCode == Keys.F12 && System.Diagnostics.Debugger.IsAttached) + { + // F12: 다음 sm_Running 호출시 디버거 중단 + RequestDebugBreak = true; + var lastCall = DateTime.Now - lastSmRunningTime; + PUB.log.Add("DEBUG", $"F12 pressed - 마지막 sm_Running 호출: {lastCall.TotalSeconds:F1}초 전, IsThreadRun: {PUB.sm.IsThreadRun}"); + System.Windows.Forms.MessageBox.Show( + $"다음 sm_Running 호출시 디버거가 중단됩니다.\n\n" + + $"마지막 호출: {lastCall.TotalSeconds:F1}초 전\n" + + $"IsThreadRun: {PUB.sm.IsThreadRun}\n" + + $"Current Step: {PUB.sm.Step}", + "디버그 모드", + System.Windows.Forms.MessageBoxButtons.OK, + System.Windows.Forms.MessageBoxIcon.Information); + } if (DateTime.Now > PUB.LastInputTime) PUB.LastInputTime = DateTime.Now; }; @@ -239,15 +259,67 @@ namespace Project PUB.sm.SetMsgOptOff(); //모든 메세지출력을 해제한다. (이벤트는 동작함) PUB.log.Add("State Machine Start"); - PUB.sm = new StateMachine.StateMachine(); + + try + { + PUB.sm = new StateMachine.StateMachine(); + PUB.log.Add("StateMachine", $"객체 생성 완료 - Type: {PUB.sm.GetType().FullName}"); + + // StateMachine 객체의 속성 확인 + var smType = PUB.sm.GetType(); + var properties = smType.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + PUB.log.Add("StateMachine", $"Public Properties: {properties.Length}개"); + + var methods = smType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly); + PUB.log.Add("StateMachine", $"Public Methods: {methods.Length}개"); + } + catch (Exception ex) + { + PUB.log.AddE($"StateMachine 객체 생성 실패: {ex.Message}"); + PUB.log.AddE($"StackTrace: {ex.StackTrace}"); + throw; + } + PUB.sm.StepChanged += sm_StepChanged; + PUB.log.Add("StateMachine", "StepChanged 이벤트 등록 완료"); + PUB.sm.Message += sm_Message; + PUB.log.Add("StateMachine", "Message 이벤트 등록 완료"); + PUB.sm.Running += sm_Running; + PUB.log.Add("StateMachine", "Running 이벤트 등록 완료"); + PUB.sm.SPS += sm_SPS; + PUB.log.Add("StateMachine", "SPS 이벤트 등록 완료"); + PUB.sm.Start(); + PUB.log.Add("StateMachine", $"Start() 호출 완료 - IsThreadRun:{PUB.sm.IsThreadRun}"); + + // 스레드 시작 대기 (최대 3초) + for (int i = 0; i < 30; i++) + { + System.Threading.Thread.Sleep(100); + if (PUB.sm.IsThreadRun) + { + PUB.log.Add("StateMachine", $"스레드 시작 확인됨 ({i * 100}ms 소요)"); + break; + } + } + + if (!PUB.sm.IsThreadRun) + { + PUB.log.AddE( "경고: 3초 대기 후에도 스레드가 시작되지 않음!"); + System.Windows.Forms.MessageBox.Show( + "상태머신 스레드가 시작되지 않았습니다.\n" + + "로그 파일을 확인하세요.", + "오류", + System.Windows.Forms.MessageBoxButtons.OK, + System.Windows.Forms.MessageBoxIcon.Error); + } tmDisplay.Tick += tmDisplay_Tick; tmDisplay.Start(); //start Display + PUB.log.Add("Display", "Display Timer 시작 완료"); this.btDebug.Visible = System.Diagnostics.Debugger.IsAttached || PUB.setting.UseDebugMode; @@ -1015,6 +1087,68 @@ namespace Project } } + private void DumpAllThreadsState() + { + try + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("===== 스레드 상태 덤프 ====="); + sb.AppendLine($"시간: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}"); + sb.AppendLine($"마지막 sm_Running 호출: {(DateTime.Now - lastSmRunningTime).TotalSeconds:F1}초 전"); + sb.AppendLine($"sm_Running 호출 횟수: {sm_Running_CallCount}"); + sb.AppendLine($"IsThreadRun: {PUB.sm.IsThreadRun}"); + sb.AppendLine($"Current Step: {PUB.sm.Step}"); + sb.AppendLine($"Current RunStep: {PUB.sm.RunStep}"); + sb.AppendLine(); + + var process = System.Diagnostics.Process.GetCurrentProcess(); + sb.AppendLine($"총 스레드 수: {process.Threads.Count}"); + sb.AppendLine(); + + foreach (System.Diagnostics.ProcessThread thread in process.Threads) + { + sb.AppendLine($"Thread {thread.Id}:"); + sb.AppendLine($" State: {thread.ThreadState}"); + sb.AppendLine($" Priority: {thread.PriorityLevel}"); + if (thread.ThreadState == System.Diagnostics.ThreadState.Wait) + { + sb.AppendLine($" WaitReason: {thread.WaitReason}"); + } + sb.AppendLine(); + } + + var dump = sb.ToString(); + PUB.log.Add("THREAD_DUMP", dump); + Console.WriteLine(dump); + + MessageBox.Show( + $"스레드 덤프 완료\n로그 파일에 저장되었습니다.\n\n" + + $"마지막 sm_Running: {(DateTime.Now - lastSmRunningTime).TotalSeconds:F1}초 전\n" + + $"IsThreadRun: {PUB.sm.IsThreadRun}", + "스레드 덤프", + MessageBoxButtons.OK, + MessageBoxIcon.Information); + } + catch (Exception ex) + { + PUB.log.AddE($"DumpAllThreadsState 오류: {ex.Message}"); + } + } + + private void stateMachineDebugToolStripMenuItem_Click(object sender, EventArgs e) + { + try + { + var debugForm = new Dialog.fStateMachineDebug(); + debugForm.Show(); + } + catch (Exception ex) + { + MessageBox.Show($"디버그 창을 열 수 없습니다:\n{ex.Message}", "오류", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + private void editorToolStripMenuItem_Click(object sender, EventArgs e) { try diff --git a/Cs_HMI/StateMachine/EventArgs.cs b/Cs_HMI/StateMachine/EventArgs.cs index ac2850b..dbf13df 100644 --- a/Cs_HMI/StateMachine/EventArgs.cs +++ b/Cs_HMI/StateMachine/EventArgs.cs @@ -20,7 +20,27 @@ namespace Project.StateMachine public event EventHandler Message; void RaiseMessage(string header, string msg) { - if (Message != null) Message(this, new StateMachineMessageEventArgs(header, msg)); + if (Message != null) + { + try + { + // 비동기로 이벤트 발생 (블로킹 방지) + var handler = Message; + if (handler != null) + { + var args = new StateMachineMessageEventArgs(header, msg); + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try + { + handler(this, args); + } + catch { /* 이벤트 핸들러 예외 무시 */ } + }); + } + } + catch { /* 예외 무시 */ } + } } public class StepChangeEventArgs : EventArgs diff --git a/Cs_HMI/StateMachine/StateMachine.cs b/Cs_HMI/StateMachine/StateMachine.cs index 0d53f61..42ce385 100644 --- a/Cs_HMI/StateMachine/StateMachine.cs +++ b/Cs_HMI/StateMachine/StateMachine.cs @@ -87,18 +87,64 @@ namespace Project.StateMachine private Boolean _isthreadrun; public Boolean IsThreadRun { get { return _isthreadrun; } } + public int LoopCount { get { return _loopCount; } } + public DateTime LastLoopTime { get { return _lastLoopTime; } } + + private int _loopCount = 0; + private DateTime _lastLoopTime = DateTime.Now; void Loop() { _isthreadrun = true; if (GetMsgOpt(EMsgOpt.NORMAL)) RaiseMessage("SM", "Start"); - while (bLoop) + + try { - RunSpeed = DateTime.Now - UpdateTime; //이전업데이트에서 현재 시간의 오차 한바퀴 시간이 표시됨 - UpdateTime = DateTime.Now; + while (bLoop) + { + _loopCount++; + _lastLoopTime = DateTime.Now; + + // 루프 동작 확인용 로그 (10초마다) + if (_loopCount % 10000 == 0) + { + RaiseMessage("SM-LOOP", $"Loop alive - Count:{_loopCount}, bLoop:{bLoop}, Step:{Step}"); + } + + RunSpeed = DateTime.Now - UpdateTime; //이전업데이트에서 현재 시간의 오차 한바퀴 시간이 표시됨 + UpdateTime = DateTime.Now; //항상 작동하는 경우 - SPS?.Invoke(this, new EventArgs()); + try + { + var spsHandler = SPS; + if (spsHandler != null) + { + var spsStartTime = DateTime.Now; + var spsTask = System.Threading.Tasks.Task.Run(() => + { + try + { + spsHandler(this, new EventArgs()); + } + catch (Exception ex) + { + RaiseMessage("SM-ERROR-SPS", $"SPS Exception: {ex.Message}\n{ex.StackTrace}"); + } + }); + + // 타임아웃 1초 + if (!spsTask.Wait(1000)) + { + var elapsed = (DateTime.Now - spsStartTime).TotalSeconds; + RaiseMessage("SM-TIMEOUT", $"SPS 이벤트 타임아웃 ({elapsed:F2}초 초과) - Step:{Step}"); + } + } + } + catch (Exception ex) + { + RaiseMessage("SM-ERROR-SPS", $"SPS Exception: {ex.Message}\n{ex.StackTrace}"); + } //작동스텝이 변경되었다면 그것을 알림 처리한다. if (GetNewStep != Step) @@ -118,22 +164,47 @@ namespace Project.StateMachine //동작중에 발생하는 이벤트 - if (Running != null) + var runningHandler = Running; + if (runningHandler != null) { try { - Running(this, new RunningEventArgs(Step, firstRun, StepRunTime)); + var runningStartTime = DateTime.Now; + var runningTask = System.Threading.Tasks.Task.Run(() => + { + try + { + runningHandler(this, new RunningEventArgs(Step, firstRun, StepRunTime)); + } + catch (Exception ex) + { + RaiseMessage("SM-ERROR-RUNNING", $"Running Exception: {ex.Message}\n{ex.StackTrace}"); + } + }); + + // 타임아웃 2초 + if (!runningTask.Wait(2000)) + { + var elapsed = (DateTime.Now - runningStartTime).TotalSeconds; + RaiseMessage("SM-TIMEOUT", $"Running 이벤트 타임아웃 ({elapsed:F2}초 초과) - Step:{Step}, FirstRun:{firstRun}"); + } } catch (Exception ex) { - RaiseMessage("SM-ERROR", ex.Message); + RaiseMessage("SM-ERROR-RUNNING", $"Running Exception: {ex.Message}\n{ex.StackTrace}"); } + } System.Threading.Thread.Sleep(1); } - - System.Threading.Thread.Sleep(1); } - _isthreadrun = false; - if (GetMsgOpt(EMsgOpt.NORMAL)) RaiseMessage("SM", "Stop"); + catch (Exception ex) + { + RaiseMessage("SM-FATAL", $"Loop Fatal Exception: {ex.Message}\n{ex.StackTrace}"); + } + finally + { + _isthreadrun = false; + if (GetMsgOpt(EMsgOpt.NORMAL)) RaiseMessage("SM", $"Stop - LoopCount:{_loopCount}"); + } } @@ -175,7 +246,18 @@ namespace Project.StateMachine OldStep = _step; _step = newstep_; _newstep = newstep_; - StepChanged?.Invoke(this, new StepChangeEventArgs(OldStep, newstep_)); + + // 비동기로 이벤트 발생 (블로킹 방지) + var handler = StepChanged; + if (handler != null) + { + var args = new StepChangeEventArgs(OldStep, newstep_); + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { handler(this, args); } + catch { /* 이벤트 핸들러 예외 무시 */ } + }); + } } else { @@ -284,7 +366,18 @@ namespace Project.StateMachine { var ostep = _step; OldStep = _step; _step = _newstep; - StepChanged?.Invoke(this, new StepChangeEventArgs(ostep, _step)); + + // 비동기로 이벤트 발생 (블로킹 방지) + var handler = StepChanged; + if (handler != null) + { + var args = new StepChangeEventArgs(ostep, _step); + System.Threading.ThreadPool.QueueUserWorkItem(_ => + { + try { handler(this, args); } + catch { /* 이벤트 핸들러 예외 무시 */ } + }); + } } //171214 /// diff --git a/agv_log_report_v1.py b/agv_log_report_v1.py new file mode 100644 index 0000000..29c8311 --- /dev/null +++ b/agv_log_report_v1.py @@ -0,0 +1,948 @@ +""" +AGV 종합 분석 리포트 생성 스크립트 +- BMS 배터리 데이터 분석 (0x03: 배터리 상태, 0x04: 셀 전압) +- 상차작업완료 집계 +- 충전상태전환 이벤트 분석 +- 셀 전압 불균형 분석 +- 시간대별 종합 리포트 및 엑셀 차트 생성 +""" + +import re +from datetime import datetime, timedelta +from collections import defaultdict +import pandas as pd +from openpyxl import load_workbook +from openpyxl.chart import LineChart, Reference, BarChart, AreaChart +from openpyxl.styles import Font, Alignment, PatternFill +import os +import glob + +print("=" * 80) +print("AGV 종합 분석 리포트 생성 (셀 전압 분석 포함)") +print("=" * 80) + +# ============================================================================ +# 1. BMS 배터리 데이터 파싱 (0x03: 배터리 상태) +# ============================================================================ + +def parse_bms_packet(hex_string): + """BMS 배터리 상태 패킷 파싱 (0x03)""" + try: + bytes_data = [int(x, 16) for x in hex_string.split()] + if len(bytes_data) < 34 or bytes_data[0] != 0xDD or bytes_data[-1] != 0x77: + return None + if bytes_data[1] != 0x03: # 배터리 상태 정보만 + return None + + volt_raw = (bytes_data[4] << 8) | bytes_data[5] + voltage = volt_raw / 100.0 + cur_amp = (bytes_data[8] << 8) | bytes_data[9] + max_amp = (bytes_data[10] << 8) | bytes_data[11] + level_direct = bytes_data[23] + temp1_raw = (bytes_data[27] << 8) | bytes_data[28] + temp1 = (temp1_raw - 2731) / 10.0 + + return { + 'voltage': voltage, + 'current_amp': cur_amp, + 'max_amp': max_amp, + 'level': level_direct, + 'temp': temp1 + } + except: + return None + +def read_bms_log(file_path): + """BMS 로그 파일 읽기 (배터리 상태)""" + encodings = ['utf-8', 'cp949', 'euc-kr'] + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + lines = f.readlines() + break + except: + continue + else: + return [] + + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\w+\s+BMS:(.*?)(?=\n|$)' + battery_data = [] + + for line in lines: + match = re.search(pattern, line) + if match: + timestamp_str = match.group(1) + packet_hex = match.group(2).strip() + parsed = parse_bms_packet(packet_hex) + if parsed: + timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') + battery_data.append({ + 'timestamp': timestamp, + **parsed + }) + + return battery_data + +# ============================================================================ +# 1-2. BMS 셀 전압 데이터 파싱 (0x04: 셀 전압) +# ============================================================================ + +def parse_cell_voltage_packet(hex_string): + """BMS 셀 전압 패킷 파싱 (0x04)""" + try: + bytes_data = [int(x, 16) for x in hex_string.split()] + if len(bytes_data) < 23 or bytes_data[0] != 0xDD or bytes_data[-1] != 0x77: + return None + if bytes_data[1] != 0x04: # 셀 전압 정보만 + return None + + # 8개 셀 전압 추출 + voltages = [] + for i in range(8): + v_raw = (bytes_data[4 + i*2] << 8) | bytes_data[5 + i*2] + voltages.append(v_raw / 1000.0) + + return { + 'cell1': voltages[0], + 'cell2': voltages[1], + 'cell3': voltages[2], + 'cell4': voltages[3], + 'cell5': voltages[4], + 'cell6': voltages[5], + 'cell7': voltages[6], + 'cell8': voltages[7], + 'max_voltage': max(voltages), + 'min_voltage': min(voltages), + 'voltage_diff': max(voltages) - min(voltages), + 'avg_voltage': sum(voltages) / len(voltages) + } + except: + return None + +def read_cell_voltage_log(file_path): + """셀 전압 로그 파일 읽기""" + encodings = ['utf-8', 'cp949', 'euc-kr'] + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + lines = f.readlines() + break + except: + continue + else: + return [] + + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\w+\s+BMS:(.*?)(?=\n|$)' + cell_data = [] + + for line in lines: + match = re.search(pattern, line) + if match: + timestamp_str = match.group(1) + packet_hex = match.group(2).strip() + parsed = parse_cell_voltage_packet(packet_hex) + if parsed: + timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') + cell_data.append({ + 'timestamp': timestamp, + **parsed + }) + + return cell_data + +# ============================================================================ +# 2. 상차작업완료 카운트 +# ============================================================================ + +def read_loading_complete(file_path): + """상차작업완료 메시지 추출""" + encodings = ['utf-8', 'cp949', 'euc-kr'] + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + break + except: + continue + else: + return [] + + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*상차작업완료\(([^)]+)\)' + matches = re.findall(pattern, content) + + results = [] + for timestamp_str, location in matches: + timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') + results.append({ + 'timestamp': timestamp, + 'location': location + }) + + return results + +# ============================================================================ +# 3. 충전상태전환 이벤트 +# ============================================================================ + +def read_charge_status(file_path): + """충전상태전환 메시지 추출""" + encodings = ['utf-8', 'cp949', 'euc-kr'] + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + lines = f.readlines() + break + except: + continue + else: + return [] + + pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*충전상태전환\s+(True|False)' + results = [] + + for line in lines: + match = re.search(pattern, line) + if match: + timestamp_str = match.group(1) + status = match.group(2) + timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') + results.append({ + 'timestamp': timestamp, + 'status': status == 'True' + }) + + return results + +# ============================================================================ +# 4. 데이터 읽기 +# ============================================================================ + +print("\n데이터 로딩 중...") + +# 현재 실행 폴더 기준 (서브폴더 포함) +base_path = os.getcwd() +print(f" 분석 폴더: {base_path} (서브폴더 포함)") + +# 파일 패턴으로 자동 검색 (재귀 검색) +# BMS 파일들 찾기 (*_bms.txt) +bms_files = sorted(glob.glob(os.path.join(base_path, "**", "*_bms.txt"), recursive=True)) +print(f" BMS 파일: {len(bms_files)}개 발견") +for f in bms_files: + rel_path = os.path.relpath(f, base_path) + print(f" - {rel_path}") + +# 운용기록 로그 파일들 찾기 (202*.txt, 단 _bms.txt 제외) +log_files = sorted([f for f in glob.glob(os.path.join(base_path, "**", "202*.txt"), recursive=True) + if not f.endswith("_bms.txt")]) +print(f" 운용기록 파일: {len(log_files)}개 발견") +for f in log_files: + rel_path = os.path.relpath(f, base_path) + print(f" - {rel_path}") + +# BMS 배터리 상태 데이터 (0x03) +all_battery = [] +for bms_file in bms_files: + data = read_bms_log(bms_file) + all_battery.extend(data) + rel_path = os.path.relpath(bms_file, base_path) + print(f" {rel_path}: {len(data)}개 배터리 데이터") +all_battery.sort(key=lambda x: x['timestamp']) + +print(f" 배터리 데이터 총합: {len(all_battery)}개") + +# BMS 셀 전압 데이터 (0x04) +all_cells = [] +for bms_file in bms_files: + data = read_cell_voltage_log(bms_file) + all_cells.extend(data) + rel_path = os.path.relpath(bms_file, base_path) + print(f" {rel_path}: {len(data)}개 셀 전압 데이터") +all_cells.sort(key=lambda x: x['timestamp']) + +print(f" 셀 전압 데이터 총합: {len(all_cells)}개") + +# 상차작업완료 데이터 +all_loading = [] +for log_file in log_files: + data = read_loading_complete(log_file) + all_loading.extend(data) + rel_path = os.path.relpath(log_file, base_path) + print(f" {rel_path}: {len(data)}건 작업완료") +all_loading.sort(key=lambda x: x['timestamp']) + +print(f" 상차작업완료 총합: {len(all_loading)}건") + +# 충전상태전환 데이터 +all_charge = [] +for log_file in log_files: + data = read_charge_status(log_file) + all_charge.extend(data) + rel_path = os.path.relpath(log_file, base_path) + print(f" {rel_path}: {len(data)}건 충전이벤트") +all_charge.sort(key=lambda x: x['timestamp']) + +print(f" 충전상태전환 총합: {len(all_charge)}건") + +# ============================================================================ +# 4-2. 셀 불균형 분석 +# ============================================================================ + +print("\n셀 불균형 분석 중...") + +if all_cells: + # 불균형 기준: 0.1V 이상 차이 + critical_imbalance = [c for c in all_cells if c['voltage_diff'] > 0.1] + warning_imbalance = [c for c in all_cells if 0.05 < c['voltage_diff'] <= 0.1] + + print(f" 심각한 불균형 (>0.1V): {len(critical_imbalance)}건") + print(f" 경고 수준 불균형 (0.05~0.1V): {len(warning_imbalance)}건") + + # 일별 셀 전압 분석 + from collections import defaultdict + daily_cells = defaultdict(list) + for c in all_cells: + date_key = c['timestamp'].strftime('%Y-%m-%d') + daily_cells[date_key].append(c) + + for date_key in sorted(daily_cells.keys()): + day_data = daily_cells[date_key] + print(f"\n[{date_key} 셀 전압 분석]") + print(f" 측정 건수: {len(day_data)}건") + + max_diff = max(c['voltage_diff'] for c in day_data) + avg_diff = sum(c['voltage_diff'] for c in day_data) / len(day_data) + print(f" 최대 불균형: {max_diff:.3f}V") + print(f" 평균 불균형: {avg_diff:.3f}V") + + # 최대 불균형 시점 찾기 + max_imbalance = max(day_data, key=lambda x: x['voltage_diff']) + print(f" 최대 불균형 시점: {max_imbalance['timestamp'].strftime('%H:%M:%S')}") + print(f" 셀 전압: C1={max_imbalance['cell1']:.3f}V, C2={max_imbalance['cell2']:.3f}V, " + f"C3={max_imbalance['cell3']:.3f}V, C4={max_imbalance['cell4']:.3f}V") + print(f" C5={max_imbalance['cell5']:.3f}V, C6={max_imbalance['cell6']:.3f}V, " + f"C7={max_imbalance['cell7']:.3f}V, C8={max_imbalance['cell8']:.3f}V") + print(f" 전압 차이: {max_imbalance['voltage_diff']:.3f}V " + f"(최고 {max_imbalance['max_voltage']:.3f}V - 최저 {max_imbalance['min_voltage']:.3f}V)") + + # 해당 일의 심각한 불균형 건수 + day_critical = [c for c in day_data if c['voltage_diff'] > 0.1] + day_warning = [c for c in day_data if 0.05 < c['voltage_diff'] <= 0.1] + print(f" 심각한 불균형: {len(day_critical)}건, 경고 수준: {len(day_warning)}건") + +# ============================================================================ +# 5. 시간대별 집계 (1시간 단위) +# ============================================================================ + +print("\n시간대별 데이터 집계 중...") + +# 시작/종료 시간 결정 +if all_battery: + start_time = all_battery[0]['timestamp'] + end_time = all_battery[-1]['timestamp'] +else: + start_time = datetime(2025, 11, 5, 0, 0, 0) + end_time = datetime(2025, 11, 6, 23, 59, 59) + +# 1시간 단위 시간 슬롯 생성 +time_slots = [] +current = start_time.replace(minute=0, second=0, microsecond=0) +while current <= end_time: + time_slots.append(current) + current += timedelta(hours=1) + +# 각 시간대별 데이터 집계 +timeline_data = [] + +for slot in time_slots: + slot_end = slot + timedelta(hours=1) + + # 배터리 데이터 평균 + slot_battery = [b for b in all_battery if slot <= b['timestamp'] < slot_end] + if slot_battery: + avg_voltage = sum(b['voltage'] for b in slot_battery) / len(slot_battery) + avg_amp = sum(b['current_amp'] for b in slot_battery) / len(slot_battery) + avg_level = sum(b['level'] for b in slot_battery) / len(slot_battery) + avg_temp = sum(b['temp'] for b in slot_battery) / len(slot_battery) + battery_count = len(slot_battery) + else: + avg_voltage = avg_amp = avg_level = avg_temp = battery_count = 0 + + # 작업 완료 카운트 + slot_loading = [l for l in all_loading if slot <= l['timestamp'] < slot_end] + loading_count = len(slot_loading) + + # 작업 위치별 카운트 + location_counts = defaultdict(int) + for l in slot_loading: + location_counts[l['location']] += 1 + + # 충전 상태 - 현재 시간이 충전 구간에 포함되는지 확인 + is_charging = False + for c in all_charge: + if c['timestamp'] <= slot: + is_charging = c['status'] + elif c['timestamp'] > slot_end: + break + + # 슬롯 내 충전상태전환 이벤트 확인 + slot_charge = [c for c in all_charge if slot <= c['timestamp'] < slot_end] + if slot_charge: + is_charging = slot_charge[-1]['status'] + + charging = "충전중" if is_charging else "-" + charging_indicator = 100 if is_charging else 0 + + timeline_data.append({ + '시간대': slot.strftime('%Y-%m-%d %H:%M'), + '평균전압(V)': round(avg_voltage, 2) if avg_voltage > 0 else '', + '평균용량(mAh)': int(avg_amp) if avg_amp > 0 else '', + '평균잔량(%)': int(avg_level) if avg_level > 0 else '', + '평균온도(°C)': round(avg_temp, 1) if avg_temp > 0 else '', + '작업완료건수': loading_count, + 'F1': location_counts.get('F1', 0), + 'F2': location_counts.get('F2', 0), + 'F3': location_counts.get('F3', 0), + 'F4': location_counts.get('F4', 0), + 'F5': location_counts.get('F5', 0), + 'F6': location_counts.get('F6', 0), + '충전상태': charging, + '충전구간': charging_indicator, + '배터리측정수': battery_count + }) + +print(f" 시간대별 데이터: {len(timeline_data)}개 슬롯") + +# ============================================================================ +# 5-2. 일자별 집계 +# ============================================================================ + +print("\n일자별 데이터 집계 중...") + +# 일자별 작업 완료 건수 집계 +daily_summary = defaultdict(lambda: { + 'date': '', + 'total_work': 0, + 'F1': 0, 'F2': 0, 'F3': 0, 'F4': 0, 'F5': 0, 'F6': 0, + 'HOME': 0, + 'charge_count': 0, + 'battery_count': 0, + 'avg_battery_level': 0 +}) + +# 작업 완료 집계 +for work in all_loading: + date_key = work['timestamp'].strftime('%Y-%m-%d') + daily_summary[date_key]['date'] = date_key + daily_summary[date_key]['total_work'] += 1 + daily_summary[date_key][work['location']] += 1 + +# 충전 이벤트 집계 +for charge in all_charge: + date_key = charge['timestamp'].strftime('%Y-%m-%d') + daily_summary[date_key]['charge_count'] += 1 + +# 배터리 데이터 집계 +battery_by_day = defaultdict(list) +for bat in all_battery: + date_key = bat['timestamp'].strftime('%Y-%m-%d') + battery_by_day[date_key].append(bat['level']) + +for date_key, levels in battery_by_day.items(): + daily_summary[date_key]['battery_count'] = len(levels) + daily_summary[date_key]['avg_battery_level'] = sum(levels) / len(levels) + +# DataFrame 생성 +daily_data = [] +for date_key in sorted(daily_summary.keys()): + data = daily_summary[date_key] + daily_data.append({ + '일자': data['date'], + '총작업건수': data['total_work'], + 'F1': data['F1'], + 'F2': data['F2'], + 'F3': data['F3'], + 'F4': data['F4'], + 'F5': data['F5'], + 'F6': data['F6'], + '충전이벤트': data['charge_count'] + }) + +print(f" 일자별 데이터: {len(daily_data)}일") + +# ============================================================================ +# 5-3. 일자별 교대조(Shift)별 집계 +# ============================================================================ + +print("\n교대조별 데이터 집계 중...") + +def get_shift(timestamp): + """시간대별 교대조 분류""" + hour = timestamp.hour + if 6 <= hour < 14: + return 'Day' + elif 14 <= hour < 22: + return 'Swing' + else: # 22:00~06:00 + return 'Night' + +# 일자별 교대조별 작업 집계 +shift_summary = defaultdict(lambda: {'date': '', 'Day': 0, 'Swing': 0, 'Night': 0}) + +for work in all_loading: + date_key = work['timestamp'].strftime('%Y-%m-%d') + shift = get_shift(work['timestamp']) + shift_summary[date_key]['date'] = date_key + shift_summary[date_key][shift] += 1 + +# DataFrame 생성 +shift_data = [] +for date_key in sorted(shift_summary.keys()): + data = shift_summary[date_key] + total = data['Day'] + data['Swing'] + data['Night'] + avg = round(total / 3, 1) if total > 0 else 0 + shift_data.append({ + '일자': data['date'], + 'day': data['Day'], + 'swing': data['Swing'], + 'night': data['Night'], + '합계': total, + '평균': avg + }) + +print(f" 교대조별 데이터: {len(shift_data)}일") + +# ============================================================================ +# 6. 엑셀 리포트 생성 +# ============================================================================ + +print("\n엑셀 리포트 생성 중...") + +# 출력 파일명 동적 생성 (시작일~종료일) +if all_battery: + start_date = all_battery[0]['timestamp'].strftime('%Y%m%d') + end_date = all_battery[-1]['timestamp'].strftime('%Y%m%d') + output_filename = f"agv_log_report_{start_date}~{end_date}.xlsx" +else: + output_filename = "agv_log_report.xlsx" + +output_file = os.path.join(base_path, output_filename) +print(f" 출력 파일: {output_filename}") + +# DataFrame 생성 +df_timeline = pd.DataFrame(timeline_data) + +# 배터리 상세 데이터 +df_battery = pd.DataFrame([{ + '시간': b['timestamp'].strftime('%Y-%m-%d %H:%M:%S'), + '전압(V)': round(b['voltage'], 2), + '남은용량(mAh)': b['current_amp'], + '총용량(mAh)': b['max_amp'], + '잔량(%)': b['level'], + '온도(°C)': round(b['temp'], 1) +} for b in all_battery]) + +# 작업 상세 데이터 +df_loading = pd.DataFrame([{ + '시간': l['timestamp'].strftime('%Y-%m-%d %H:%M:%S'), + '위치': l['location'] +} for l in all_loading]) + +# 충전 이벤트 데이터 +df_charge = pd.DataFrame([{ + '시간': c['timestamp'].strftime('%Y-%m-%d %H:%M:%S'), + '충전상태': '시작' if c['status'] else '종료' +} for c in all_charge]) + +# 셀 전압 상세 데이터 +df_cells = pd.DataFrame([{ + '시간': c['timestamp'].strftime('%Y-%m-%d %H:%M:%S'), + 'Cell1(V)': round(c['cell1'], 3), + 'Cell2(V)': round(c['cell2'], 3), + 'Cell3(V)': round(c['cell3'], 3), + 'Cell4(V)': round(c['cell4'], 3), + 'Cell5(V)': round(c['cell5'], 3), + 'Cell6(V)': round(c['cell6'], 3), + 'Cell7(V)': round(c['cell7'], 3), + 'Cell8(V)': round(c['cell8'], 3), + '최고전압(V)': round(c['max_voltage'], 3), + '최저전압(V)': round(c['min_voltage'], 3), + '전압차(V)': round(c['voltage_diff'], 3), + '평균전압(V)': round(c['avg_voltage'], 3) +} for c in all_cells]) if all_cells else pd.DataFrame() + +# 일자별 요약 DataFrame +df_daily = pd.DataFrame(daily_data) +df_shift = pd.DataFrame(shift_data) + +# 엑셀 저장 +with pd.ExcelWriter(output_file, engine='openpyxl') as writer: + df_daily.to_excel(writer, sheet_name='일자별작업요약', index=False) + # 교대조별 데이터를 같은 시트에 추가 (일자별 데이터 아래 3행 띄우고) + df_shift.to_excel(writer, sheet_name='일자별작업요약', startrow=len(df_daily)+3, index=False) + + df_timeline.to_excel(writer, sheet_name='시간대별종합', index=False) + df_battery.to_excel(writer, sheet_name='배터리상세', index=False) + df_loading.to_excel(writer, sheet_name='작업상세', index=False) + df_charge.to_excel(writer, sheet_name='충전이벤트', index=False) + if not df_cells.empty: + df_cells.to_excel(writer, sheet_name='셀전압상세', index=False) + +# ============================================================================ +# 7. 차트 추가 +# ============================================================================ + +print("차트 생성 중...") + +wb = load_workbook(output_file) + +# ============================================================================ +# 7-1. 일자별작업요약 시트 스타일 및 차트 +# ============================================================================ + +ws_daily = wb['일자별작업요약'] + +# 헤더 스타일 +header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") +header_font = Font(color="FFFFFF", bold=True) + +for cell in ws_daily[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center') + +# 열 너비 조정 +ws_daily.column_dimensions['A'].width = 12 +for col in ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']: + ws_daily.column_dimensions[col].width = 12 + +# 일자별 작업 건수 차트 +chart_daily_work = BarChart() +chart_daily_work.type = "col" +chart_daily_work.title = "일자별 작업 완료 건수" +chart_daily_work.y_axis.title = '작업 건수' +chart_daily_work.x_axis.title = '일자' +chart_daily_work.height = 12 +chart_daily_work.width = 20 + +# 총작업건수 데이터 +data = Reference(ws_daily, min_col=2, min_row=1, max_row=len(daily_data)+1) +cats = Reference(ws_daily, min_col=1, min_row=2, max_row=len(daily_data)+1) +chart_daily_work.add_data(data, titles_from_data=True) +chart_daily_work.set_categories(cats) + +ws_daily.add_chart(chart_daily_work, "N2") + +# 일자별 위치별 작업 건수 차트 (누적 막대) +chart_daily_location = BarChart() +chart_daily_location.type = "col" +chart_daily_location.grouping = "stacked" +chart_daily_location.title = "일자별 위치별 작업 건수 (누적)" +chart_daily_location.y_axis.title = '작업 건수' +chart_daily_location.x_axis.title = '일자' +chart_daily_location.height = 12 +chart_daily_location.width = 20 + +# F1~F6 데이터 (3~8열) +data = Reference(ws_daily, min_col=3, max_col=8, min_row=1, max_row=len(daily_data)+1) +cats = Reference(ws_daily, min_col=1, min_row=2, max_row=len(daily_data)+1) +chart_daily_location.add_data(data, titles_from_data=True) +chart_daily_location.set_categories(cats) + +ws_daily.add_chart(chart_daily_location, "N22") + +# 교대조별 데이터 영역 스타일 및 차트 +shift_start_row = len(daily_data) + 4 # 일자별 데이터 + 빈 행 + 헤더 + +# 교대조별 헤더 스타일 +for col_idx in range(1, 7): # A~F 열 (일자, day, swing, night, 합계, 평균) + cell = ws_daily.cell(row=shift_start_row, column=col_idx) + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + cell.alignment = Alignment(horizontal='center') + +# 교대조별 작업 건수 차트 (혼합 차트: 막대 + 선) +chart_shift = BarChart() +chart_shift.type = "col" +chart_shift.grouping = "clustered" +chart_shift.title = "일자별 교대조별 작업 건수" +chart_shift.y_axis.title = '작업 건수' +chart_shift.x_axis.title = '일자' +chart_shift.height = 12 +chart_shift.width = 20 + +# Day, Swing, Night 데이터 (막대 차트) +data = Reference(ws_daily, min_col=2, max_col=4, min_row=shift_start_row, max_row=shift_start_row+len(shift_data)) +cats = Reference(ws_daily, min_col=1, min_row=shift_start_row+1, max_row=shift_start_row+len(shift_data)) +chart_shift.add_data(data, titles_from_data=True) +chart_shift.set_categories(cats) + +# 데이터 레이블 추가 (숫자만 표시) +from openpyxl.chart.label import DataLabelList +for series in chart_shift.series: + series.dLbls = DataLabelList() + series.dLbls.showVal = True + series.dLbls.showCatName = False + series.dLbls.showSerName = False + series.dLbls.showPercent = False + series.dLbls.showLeaderLines = False + +# 평균 데이터 (선 차트) +line_chart = LineChart() +line_data = Reference(ws_daily, min_col=6, min_row=shift_start_row, max_row=shift_start_row+len(shift_data)) +line_chart.add_data(line_data, titles_from_data=True) +line_chart.set_categories(cats) + +# 선 차트에 데이터 레이블 추가 +for series in line_chart.series: + series.dLbls = DataLabelList() + series.dLbls.showVal = True + series.dLbls.showCatName = False + series.dLbls.showSerName = False + series.dLbls.showPercent = False + series.dLbls.showLeaderLines = False + +# 혼합 차트 조합 +chart_shift += line_chart + +ws_daily.add_chart(chart_shift, "N42") + +# ============================================================================ +# 7-2. 시간대별종합 시트 스타일 +# ============================================================================ + +ws = wb['시간대별종합'] + +# 헤더 스타일 +for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center') + +# 열 너비 조정 +ws.column_dimensions['A'].width = 18 +ws.column_dimensions['B'].width = 12 +ws.column_dimensions['C'].width = 14 +ws.column_dimensions['D'].width = 12 +ws.column_dimensions['E'].width = 12 +ws.column_dimensions['F'].width = 12 +ws.column_dimensions['L'].width = 12 + +# 차트 1: 배터리 잔량 추이 +chart1 = LineChart() +chart1.title = "배터리 잔량 추이" +chart1.style = 10 +chart1.y_axis.title = '배터리 잔량 (%)' +chart1.x_axis.title = '시간대' +chart1.height = 10 +chart1.width = 20 + +data = Reference(ws, min_col=4, min_row=1, max_row=len(timeline_data)+1) +cats = Reference(ws, min_col=1, min_row=2, max_row=len(timeline_data)+1) +chart1.add_data(data, titles_from_data=True) +chart1.set_categories(cats) + +ws.add_chart(chart1, "N2") + +# 차트 2: 작업 건수 추이 +chart2 = BarChart() +chart2.type = "col" +chart2.title = "시간대별 작업 완료 건수" +chart2.y_axis.title = '작업 건수' +chart2.x_axis.title = '시간대' +chart2.height = 10 +chart2.width = 20 + +data = Reference(ws, min_col=6, min_row=1, max_row=len(timeline_data)+1) +cats = Reference(ws, min_col=1, min_row=2, max_row=len(timeline_data)+1) +chart2.add_data(data, titles_from_data=True) +chart2.set_categories(cats) + +ws.add_chart(chart2, "N22") + +# 차트 3: 전압 추이 +chart3 = LineChart() +chart3.title = "배터리 전압 추이" +chart3.style = 12 +chart3.y_axis.title = '전압 (V)' +chart3.x_axis.title = '시간대' +chart3.height = 10 +chart3.width = 20 + +data = Reference(ws, min_col=2, min_row=1, max_row=len(timeline_data)+1) +cats = Reference(ws, min_col=1, min_row=2, max_row=len(timeline_data)+1) +chart3.add_data(data, titles_from_data=True) +chart3.set_categories(cats) + +ws.add_chart(chart3, "N42") + +# 차트 4: 종합 혼합 차트 (배터리 잔량 + 전압 + 작업횟수 + 충전구간) +chart4_area = AreaChart() +chart4_area.title = "시간대별 종합 현황 (배터리/전압/작업/충전)" +chart4_area.style = 27 +chart4_area.y_axis.title = '배터리 잔량 (%) / 전압 (V × 10)' +chart4_area.x_axis.title = '시간대' +chart4_area.height = 15 +chart4_area.width = 30 + +# 충전 구간 배경 (면적 차트) +charging_data = Reference(ws, min_col=13, min_row=1, max_row=len(timeline_data)+1) +cats = Reference(ws, min_col=1, min_row=2, max_row=len(timeline_data)+1) +chart4_area.add_data(charging_data, titles_from_data=True) +chart4_area.set_categories(cats) + +# 배터리 잔량 선 추가 +battery_data = Reference(ws, min_col=4, min_row=1, max_row=len(timeline_data)+1) +chart4_area.add_data(battery_data, titles_from_data=True) + +# 전압 데이터 추가 +voltage_data = Reference(ws, min_col=2, min_row=1, max_row=len(timeline_data)+1) +chart4_area.add_data(voltage_data, titles_from_data=True) + +# 보조 차트: 작업횟수 (오른쪽 Y축 바 차트) +chart4_bar = BarChart() +chart4_bar.type = "col" +chart4_bar.grouping = "standard" + +work_data = Reference(ws, min_col=6, min_row=1, max_row=len(timeline_data)+1) +chart4_bar.add_data(work_data, titles_from_data=True) +chart4_bar.set_categories(cats) + +# 복합 차트 조합 +chart4_area.y_axis.crossAx = 500 +chart4_bar.y_axis.axId = 500 +chart4_bar.y_axis.title = "작업 횟수" +chart4_area += chart4_bar + +ws.add_chart(chart4_area, "A72") + +# ============================================================================ +# 8. 셀 전압 시트 스타일 및 차트 +# ============================================================================ + +if all_cells and '셀전압상세' in wb.sheetnames: + print("셀 전압 차트 생성 중...") + + ws_cells = wb['셀전압상세'] + + # 헤더 스타일 + for c_idx, col_name in enumerate(df_cells.columns, start=1): + cell = ws_cells.cell(row=1, column=c_idx) + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid") + cell.alignment = Alignment(horizontal='center') + + # 열 너비 조정 + ws_cells.column_dimensions['A'].width = 20 + for col in ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M']: + ws_cells.column_dimensions[col].width = 12 + + # 셀 전압 차이가 큰 행 강조 표시 + red_fill = PatternFill(start_color="FFCCCC", end_color="FFCCCC", fill_type="solid") + yellow_fill = PatternFill(start_color="FFFFCC", end_color="FFFFCC", fill_type="solid") + + for row_idx in range(2, len(df_cells) + 2): + voltage_diff = ws_cells.cell(row=row_idx, column=12).value + if voltage_diff and voltage_diff > 0.1: + for col_idx in range(1, 14): + ws_cells.cell(row=row_idx, column=col_idx).fill = red_fill + elif voltage_diff and voltage_diff > 0.05: + for col_idx in range(1, 14): + ws_cells.cell(row=row_idx, column=col_idx).fill = yellow_fill + + # 차트 5: 셀별 전압 추이 + chart5 = LineChart() + chart5.title = "셀별 전압 추이" + chart5.style = 10 + chart5.y_axis.title = '전압 (V)' + chart5.x_axis.title = '시간대' + chart5.height = 15 + chart5.width = 30 + + # 각 셀 데이터 추가 + for col_idx in range(2, 10): # Cell1~Cell8 + data = Reference(ws_cells, min_col=col_idx, min_row=1, max_row=len(df_cells)+1) + chart5.add_data(data, titles_from_data=True) + + cats = Reference(ws_cells, min_col=1, min_row=2, max_row=len(df_cells)+1) + chart5.set_categories(cats) + + ws_cells.add_chart(chart5, "O2") + + # 차트 6: 전압 불균형 추이 + chart6 = LineChart() + chart6.title = "셀 전압 불균형 추이 (최고-최저)" + chart6.style = 12 + chart6.y_axis.title = '전압 차이 (V)' + chart6.x_axis.title = '시간대' + chart6.height = 12 + chart6.width = 30 + + data = Reference(ws_cells, min_col=12, min_row=1, max_row=len(df_cells)+1) + chart6.add_data(data, titles_from_data=True) + chart6.set_categories(cats) + + ws_cells.add_chart(chart6, "O32") + +wb.save(output_file) + +print(f"\n리포트 생성 완료: {output_file}") + +# ============================================================================ +# 9. 요약 통계 +# ============================================================================ + +print("\n" + "=" * 80) +print("종합 분석 요약") +print("=" * 80) + +print(f"\n[분석 기간]") +print(f" {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')}") + +print(f"\n[배터리 통계]") +if all_battery: + print(f" 최소 잔량: {min(b['level'] for b in all_battery)}%") + print(f" 최대 잔량: {max(b['level'] for b in all_battery)}%") + print(f" 평균 잔량: {sum(b['level'] for b in all_battery) / len(all_battery):.1f}%") + print(f" 최저 전압: {min(b['voltage'] for b in all_battery):.2f}V") + print(f" 최고 전압: {max(b['voltage'] for b in all_battery):.2f}V") + +print(f"\n[작업 통계]") +print(f" 총 작업 완료: {len(all_loading)}건") +location_stats = defaultdict(int) +for l in all_loading: + location_stats[l['location']] += 1 +for loc in sorted(location_stats.keys()): + print(f" {loc}: {location_stats[loc]}건") + +print(f"\n[충전 통계]") +charge_on_count = sum(1 for c in all_charge if c['status']) +charge_off_count = sum(1 for c in all_charge if not c['status']) +print(f" 충전 시작: {charge_on_count}회") +print(f" 충전 종료: {charge_off_count}회") + +# 셀 전압 통계 +if all_cells: + print(f"\n[셀 전압 통계]") + print(f" 최대 불균형: {max(c['voltage_diff'] for c in all_cells):.3f}V") + print(f" 평균 불균형: {sum(c['voltage_diff'] for c in all_cells) / len(all_cells):.3f}V") + print(f" 심각한 불균형 건수 (>0.1V): {len(critical_imbalance)}건") + print(f" 경고 수준 건수 (0.05~0.1V): {len(warning_imbalance)}건") + + print(f"\n[개별 셀 전압 범위]") + for i in range(1, 9): + cell_key = f'cell{i}' + cell_voltages = [c[cell_key] for c in all_cells] + print(f" Cell {i}: {min(cell_voltages):.3f}V ~ {max(cell_voltages):.3f}V " + f"(평균 {sum(cell_voltages)/len(cell_voltages):.3f}V)") + + # 가장 심각한 불균형 TOP 5 + print(f"\n[불균형 심각도 TOP 5]") + top5_imbalance = sorted(all_cells, key=lambda x: x['voltage_diff'], reverse=True)[:5] + for i, c in enumerate(top5_imbalance, 1): + print(f" {i}. {c['timestamp'].strftime('%Y-%m-%d %H:%M:%S')} - " + f"불균형: {c['voltage_diff']:.3f}V " + f"(최고 {c['max_voltage']:.3f}V - 최저 {c['min_voltage']:.3f}V)") + +print("\n" + "=" * 80) +print("분석 완료!") +print("=" * 80)