Files
ENIG/agv_log_report_v1.py
backuppc 34ad1db0e3 Fix: 상태머신 루프 블로킹 문제 수정 - SPS 이벤트 핸들러 비동기 처리 및 타임아웃 보호 추가
- sm_SPS 이벤트 핸들러에서 장치 연결 및 상태 전송을 비동기로 처리

- DeviceConnectionWorker 스레드로 장치 연결 분리

- SPS(1초), Running(2초) 타임아웃 보호 추가

- 상태머신 모니터링 디버그 창 추가 (fStateMachineDebug)

- F11/F12 단축키로 스레드 덤프 및 디버그 브레이크 지원

- RaiseMessage 이벤트 비동기 처리로 로그 블로킹 방지
2025-12-04 14:43:57 +09:00

949 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)