9 Commits

Author SHA1 Message Date
9cb4734093 csv_2_plot: fix assignment of 'elapsed_seconds' column to avoid SettingWithCopyWarning
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 16:12:51 +09:00
b734915040 monitor: reduce averaging and adjust conversion times for improved responsiveness
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 15:28:32 +09:00
a84b504733 monitor: adjust sensor period range to support lower limit of 100ms
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:17:41 +09:00
222de64932 csv_2_plot: refine Y-axis major tick interval calculation for better alignment with data ranges
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 14:16:24 +09:00
1f35c52261 csv_2_plot: fix timestamp column assignment during timezone conversion
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:45:30 +09:00
14440094ac csv_2_plot: set matplotlib backend to 'Agg' for non-GUI environments
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 13:43:09 +09:00
11f9c72543 csv_2_plot: add support for customizing x-axis grid and label intervals
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:17:04 +09:00
c98e735410 csv_2_plot: add support for plotting with relative time on the x-axis
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 12:10:25 +09:00
5a505a5205 csv_2_plot: add Y-axis max value customization for voltage, current, and power
Signed-off-by: YoungSoo Shin <shinys000114@gmail.com>
2025-12-11 11:59:55 +09:00
3 changed files with 122 additions and 45 deletions

View File

@@ -1,32 +1,53 @@
import argparse
import matplotlib
matplotlib.use('Agg')
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import pandas as pd
from dateutil.tz import gettz
from matplotlib.ticker import MultipleLocator
from matplotlib.ticker import MultipleLocator, FuncFormatter
import math
def plot_power_data(csv_path, output_path, plot_types, sources):
def plot_power_data(csv_path, output_path, plot_types, sources,
voltage_y_max=None, current_y_max=None, power_y_max=None,
relative_time=False, time_x_line=None, time_x_label=None):
"""
Reads power data from a CSV file and generates a plot image.
Args:
csv_path (str): The path to the input CSV file.
output_path (str): The path to save the output plot image.
plot_types (list): A list of strings indicating which plots to generate
(e.g., ['power', 'voltage', 'current']).
sources (list): A list of strings indicating which power sources to plot
(e.g., ['vin', 'main', 'usb']).
plot_types (list): A list of strings indicating which plots to generate.
sources (list): A list of strings indicating which power sources to plot.
voltage_y_max (float, optional): Maximum value for the voltage plot's Y-axis.
current_y_max (float, optional): Maximum value for the current plot's Y-axis.
power_y_max (float, optional): Maximum value for the power plot's Y-axis.
relative_time (bool): If True, the x-axis will show elapsed time from the start.
time_x_line (float, optional): Interval in seconds for x-axis grid lines.
time_x_label (float, optional): Interval in seconds for x-axis labels.
"""
try:
# Read the CSV file into a pandas DataFrame
df = pd.read_csv(csv_path, parse_dates=['timestamp'])
print(f"Successfully loaded {len(df)} records from '{csv_path}'")
# --- Timezone Conversion ---
local_tz = gettz()
df['timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
if df.empty:
print("CSV file is empty. Exiting.")
return
# --- Time Handling ---
x_axis_data = df['timestamp']
if relative_time:
start_time = df['timestamp'].iloc[0]
df.loc[:, 'elapsed_seconds'] = (df['timestamp'] - start_time).dt.total_seconds()
x_axis_data = df['elapsed_seconds']
print("X-axis set to relative time (elapsed seconds).")
else:
# --- Timezone Conversion for absolute time ---
local_tz = gettz()
df.loc[:, 'timestamp'] = df['timestamp'].dt.tz_convert(local_tz)
print(f"Timestamp converted to local timezone: {local_tz}")
except FileNotFoundError:
print(f"Error: The file '{csv_path}' was not found.")
@@ -59,6 +80,11 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
'voltage': {'title': 'Voltage', 'ylabel': 'Voltage (V)', 'cols': [f'{s}_voltage' for s in sources]},
'current': {'title': 'Current', 'ylabel': 'Current (A)', 'cols': [f'{s}_current' for s in sources]}
}
y_max_options = {
'power': power_y_max,
'voltage': voltage_y_max,
'current': current_y_max
}
channel_labels = [s.upper() for s in sources]
color_map = {'vin': 'red', 'main': 'green', 'usb': 'blue'}
@@ -79,7 +105,7 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
max_data_value = 0
for j, col_name in enumerate(config['cols']):
if col_name in df.columns:
ax.plot(df['timestamp'], df[col_name], label=channel_labels[j], color=channel_colors[j], zorder=2)
ax.plot(x_axis_data, df[col_name], label=channel_labels[j], color=channel_colors[j], zorder=2)
max_col_value = df[col_name].max()
if max_col_value > max_data_value:
max_data_value = max_col_value
@@ -88,7 +114,10 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
# --- Dynamic Y-axis Scaling ---
ax.set_ylim(bottom=0)
if plot_type in scale_config:
y_max_option = y_max_options.get(plot_type)
if y_max_option is not None:
ax.set_ylim(top=y_max_option)
elif plot_type in scale_config:
steps = scale_config[plot_type]['steps']
new_max = next((step for step in steps if step >= max_data_value), steps[-1])
ax.set_ylim(top=new_max)
@@ -97,27 +126,35 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
ax.set_ylabel(config['ylabel'])
ax.legend()
# --- Grid and Tick Configuration ---
# --- Y-Grid and Tick Configuration ---
y_min, y_max = ax.get_ylim()
# Keep the dynamic major_interval logic for tick LABELS
if plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5
if y_max <= 0:
major_interval = 1.0 # Default for very small or zero range
elif plot_type == 'current' and y_max <= 2.5:
major_interval = 0.5 # Maintain current behavior for very small current values
elif y_max <= 10:
major_interval = 2
major_interval = 2.0 # Maintain current behavior for small ranges where 5-unit is too coarse
elif y_max <= 25:
major_interval = 5
else:
major_interval = y_max / 5.0
major_interval = 5.0 # Already a multiple of 5
else: # y_max > 25
# Aim for major ticks that are multiples of 5.
# Calculate a rough interval to get around 5 major ticks.
rough_interval = y_max / 5.0
# Find the smallest multiple of 5 that is greater than or equal to rough_interval.
# This ensures labels are multiples of 5.
major_interval = math.ceil(rough_interval / 5.0) * 5.0
# Ensure major_interval is not 0 if y_max is small but positive.
if major_interval == 0 and y_max > 0:
major_interval = 5.0
ax.yaxis.set_major_locator(MultipleLocator(major_interval))
ax.yaxis.set_minor_locator(MultipleLocator(1))
# Disable the default major grid, but keep the minor one
ax.yaxis.grid(False, which='major')
ax.yaxis.grid(True, which='minor', linestyle='--', linewidth=0.6, zorder=0)
# Draw custom lines for 5 and 10 multiples, which are now the only major grid lines
for y_val in range(int(y_min), int(y_max) + 1):
if y_val == 0: continue
if y_val % 10 == 0:
@@ -125,43 +162,60 @@ def plot_power_data(csv_path, output_path, plot_types, sources):
elif y_val % 5 == 0:
ax.axhline(y=y_val, color='midnightblue', linestyle='--', linewidth=1.2, zorder=1)
# Keep the x-axis grid
# --- X-Grid Configuration ---
ax.xaxis.grid(True, which='major', linestyle='--', linewidth=0.8)
if time_x_line is not None:
ax.xaxis.grid(True, which='minor', linestyle=':', linewidth=0.6)
# --- Formatting the x-axis (Time) ---
local_tz = gettz()
# --- Formatting the x-axis ---
last_ax = axes[-1]
if not df.empty:
last_ax.set_xlim(df['timestamp'].iloc[0], df['timestamp'].iloc[-1])
last_ax.set_xlim(x_axis_data.iloc[0], x_axis_data.iloc[-1])
if relative_time:
plt.xlabel('Elapsed Time (seconds)')
if time_x_label is not None:
last_ax.xaxis.set_major_locator(MultipleLocator(time_x_label))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(MultipleLocator(time_x_line))
else:
local_tz = gettz()
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
if time_x_label is not None:
last_ax.xaxis.set_major_locator(mdates.SecondLocator(interval=int(time_x_label)))
else:
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
if time_x_line is not None:
last_ax.xaxis.set_minor_locator(mdates.SecondLocator(interval=int(time_x_line)))
last_ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S', tz=local_tz))
last_ax.xaxis.set_major_locator(plt.MaxNLocator(15))
plt.xlabel(f'Time ({local_tz.tzname(df["timestamp"].iloc[-1])})')
plt.xticks(rotation=45)
# --- Add a main title and subtitle ---
start_time = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time} to {end_time})'
if relative_time:
main_title = 'PowerMate Log'
else:
start_time_str = df['timestamp'].iloc[0].strftime('%Y-%m-%d %H:%M:%S')
end_time_str = df['timestamp'].iloc[-1].strftime('%H:%M:%S')
main_title = f'PowerMate Log ({start_time_str} to {end_time_str})'
subtitle_parts = []
if avg_interval_ms > 0:
subtitle_parts.append(f'Avg. Interval: {avg_interval_ms:.2f} ms')
voltage_strings = [f'{source.upper()} Avg: {avg_v:.2f} V' for source, avg_v in avg_voltages.items()]
if voltage_strings:
subtitle_parts.extend(voltage_strings)
subtitle = ' | '.join(subtitle_parts)
full_title = main_title
if subtitle:
full_title += f'\n{subtitle}'
fig.suptitle(full_title, fontsize=14)
# Adjust layout to make space for the subtitle
plt.tight_layout(rect=[0, 0, 1, 0.98])
# --- Save the plot to a file ---
@@ -192,9 +246,32 @@ def main():
help="Power sources to plot. Choose from 'vin', 'main', 'usb'. "
"Default is to plot all three."
)
parser.add_argument("--voltage_y_max", type=float, help="Maximum value for the voltage plot's Y-axis.")
parser.add_argument("--current_y_max", type=float, help="Maximum value for the current plot's Y-axis.")
parser.add_argument("--power_y_max", type=float, help="Maximum value for the power plot's Y-axis.")
parser.add_argument(
"-r", "--relative-time",
action='store_true',
help="Display the x-axis as elapsed time from the start (in seconds) instead of absolute time."
)
parser.add_argument("--time_x_line", type=float, help="Interval in seconds for x-axis grid lines.")
parser.add_argument("--time_x_label", type=float, help="Interval in seconds for x-axis labels.")
args = parser.parse_args()
plot_power_data(args.input_csv, args.output_image, args.type, args.source)
plot_power_data(
args.input_csv,
args.output_image,
args.type,
args.source,
voltage_y_max=args.voltage_y_max,
current_y_max=args.current_y_max,
power_y_max=args.power_y_max,
relative_time=args.relative_time,
time_x_line=args.time_x_line,
time_x_label=args.time_x_label
)
if __name__ == "__main__":

View File

@@ -54,9 +54,9 @@ ina3221_t ina3221 = {
.ch1 = true, // channel 1 enable
.ch2 = true, // channel 2 enable
.ch3 = true, // channel 3 enable
.avg = INA3221_AVG_64, // 64 samples average
.vbus = INA3221_CT_2116, // 2ms by channel (bus)
.vsht = INA3221_CT_2116, // 2ms by channel (shunt)
.avg = INA3221_AVG_16, // 16 samples average
.vbus = INA3221_CT_140, // 140us by channel (bus)
.vsht = INA3221_CT_1100, // 1.1ms by channel (shunt)
},
};
@@ -298,7 +298,7 @@ void init_status_monitor()
esp_err_t update_sensor_period(int period)
{
if (period < 500 || period > 10000) // 0.5 sec ~ 10 sec
if (period < 100 || period > 10000) // 0.1 sec ~ 10 sec
{
return ESP_ERR_INVALID_ARG;
}

View File

@@ -379,7 +379,7 @@
</div>
<div class="mb-3 p-3 border rounded">
<label for="period-slider" class="form-label">Sensor Period: <span class="fw-bold text-primary" id="period-value">...</span> ms</label>
<input type="range" class="form-range" id="period-slider" min="500" max="5000" step="100">
<input type="range" class="form-range" id="period-slider" min="100" max="5000" step="100">
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-primary btn-sm" id="period-apply-button">Apply</button>
</div>