File size: 4,809 Bytes
8fc241a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import os

# Set style
sns.set_style("white")  # Clean white background as requested
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial', 'Helvetica', 'Roboto', 'DejaVu Sans']
plt.rcParams['font.size'] = 12

def generate_chart():
    # Load validation data
    data_path = './data/nasa_model_validation.csv'
    if not os.path.exists(data_path):
        print(f"Error: {data_path} not found.")
        return

    df = pd.read_csv(data_path)
    
    # Filter for B0007 validation cycles
    b0007_df = df[(df['battery_id'] == 'B0007') & (df['dataset'] == 'validation')].copy()
    b0007_df = b0007_df.sort_values('cycle_index')
    
    # User requested cycle index 0 to 168
    # The actual data has 168 rows, we'll map them to a 0-indexed range for the plot if needed
    # but the actual cycle_index values are better for physical context.
    # However, the user explicitly said "Cycle index (0 to 168 for B0007)".
    # B0007 has 168 cycles total.
    cycle_index = np.arange(len(b0007_df))
    actual_soh = b0007_df['actual_soh'].values
    predicted_soh = b0007_df['predicted_soh'].values
    lower_95 = b0007_df['pred_lower_95'].values
    upper_95 = b0007_df['pred_upper_95'].values

    # Create figure - 10x6 as requested for LinkedIn
    fig, ax = plt.subplots(figsize=(10, 6), dpi=300)
    fig.patch.set_facecolor('white')

    # Plot 1: 95% prediction interval (shaded region) - Shaded region in light orange/peach with alpha=0.3
    ax.fill_between(cycle_index, 
                    lower_95, upper_95,
                    color='#ff7f0e', alpha=0.3, label='95% Confidence Interval', zorder=1)

    # Plot 2: Actual SOH (Blue line) - rgb: 31, 119, 180
    ax.plot(cycle_index, actual_soh, 
            color='#1f77b4', linewidth=2.5, label='Actual SOH (B0007)', zorder=3)

    # Plot 3: Random Forest predicted SOH (Orange line) - rgb: 255, 127, 14
    ax.plot(cycle_index, predicted_soh, 
            color='#ff7f0e', linewidth=2, linestyle='-', label='Predicted SOH (Random Forest)', zorder=2)

    # Plot 4: EOL threshold line (Red dashed)
    ax.axhline(y=0.80, color='red', linestyle='--', linewidth=1.5, 
               label='EOL Threshold (80% SOH)', zorder=0)

    # Find and annotate EOL crossing point
    crossing_idx = np.where(actual_soh < 0.80)[0]
    if len(crossing_idx) > 0:
        eol_cycle_idx = crossing_idx[0]
        ax.axvline(x=eol_cycle_idx, color='red', linestyle=':', linewidth=1, alpha=0.5, zorder=0)
        ax.text(eol_cycle_idx + 3, 0.75, f'EOL Crossing: Cycle {eol_cycle_idx}', 
                fontsize=11, color='red', fontweight='bold', verticalalignment='bottom')

    # Add metrics text box
    # Using confirmed metrics from summary
    metrics_text = (
        'Random Forest Validation\n'
        'R² = 0.9996\n'
        'MAPE = 0.14%\n'
        'MAE = 0.0012'
    )
    ax.text(0.97, 0.95, metrics_text, 
            transform=ax.transAxes, fontsize=11,
            verticalalignment='top', horizontalalignment='right',
            bbox=dict(boxstyle='round,pad=0.5', facecolor='white', alpha=0.9, edgecolor='#e0e0e0'))

    # Labels and title
    ax.set_xlabel('Cycle Index', fontsize=12, fontweight='bold', labelpad=10, color='#333333')
    ax.set_ylabel('State of Health (SOH)', fontsize=12, fontweight='bold', labelpad=10, color='#333333')
    
    # Title - 14pt as requested
    ax.set_title('Battery SOH Prediction: NASA B0007 (Held-Out Validation)\n'
                 'Random Forest with 95% Prediction Intervals', 
                 fontsize=14, fontweight='bold', pad=25, color='#1a1a1a')

    # Legend - Lower left as requested
    ax.legend(loc='lower left', fontsize=10, framealpha=1, edgecolor='#e0e0e0', facecolor='white')

    # Grid - Light gray horizontal only
    ax.grid(True, alpha=0.2, axis='y', color='#cccccc', linestyle='-')
    ax.set_axisbelow(True)

    # Clean spines - no borders as requested
    sns.despine(left=True, bottom=False)
    
    # Axis limits
    ax.set_ylim([0.70, 1.05])
    ax.set_xlim([0, len(cycle_index)])

    # Formatting ticks
    plt.xticks(fontsize=10, color='#666666')
    plt.yticks(fontsize=10, color='#666666')

    # Tight layout
    plt.tight_layout()

    # Save
    output_filename = 'zenvolt_rf_validation_B0007.png'
    plt.savefig(output_filename, dpi=300, bbox_inches='tight', facecolor='white')
    print(f"✓ High-resolution chart saved as: {output_filename}")
    
    # Also save to plots directory for app use
    plots_dir = './plots'
    os.makedirs(plots_dir, exist_ok=True)
    plt.savefig(os.path.join(plots_dir, 'linkedin_validation_B0007.png'), dpi=300, bbox_inches='tight', facecolor='white')

if __name__ == "__main__":
    generate_chart()