""" 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)