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)