Skip to content

Commit dc55f6d

Browse files
committed
Add native datetime axis support with formatting and limits configuration
1 parent ff4356e commit dc55f6d

File tree

5 files changed

+294
-0
lines changed

5 files changed

+294
-0
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
* New `SyncPlotDialog` class:
3636
* This class provides a dialog for displaying synchronized plots.
3737
* This is a complementary class to `SyncPlotWindow`, providing a modal dialog interface for synchronized plotting.
38+
* Native datetime axis support:
39+
* Added `DateTimeScaleDraw` class in `plotpy.styles.scaledraw` for formatting axis labels as date/time strings
40+
* Added `BasePlot.set_axis_datetime()` method to easily configure an axis for datetime display
41+
* Added `BasePlot.set_axis_limits_from_datetime()` method to set axis limits using datetime objects directly
42+
* Supports customizable datetime format strings using Python's `strftime` format codes
43+
* Configurable label rotation and spacing for optimal display
44+
* Example: `plot.set_axis_datetime("bottom", format="%H:%M:%S")` for time-only display
45+
* Example: `plot.set_axis_limits_from_datetime("bottom", dt1, dt2)` to zoom to a specific time range
3846

3947
🧹 API cleanup: removed deprecated update methods (use `update_item` instead)
4048

plotpy/plot/base.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@
5252
PolygonMapItem,
5353
PolygonShape,
5454
)
55+
from plotpy.plot.scaledraw import DateTimeScaleDraw
5556
from plotpy.styles.axes import AxesParam, AxeStyleParam, AxisParam, ImageAxesParam
5657
from plotpy.styles.base import GridParam, ItemParameters
5758

5859
if TYPE_CHECKING:
60+
from datetime import datetime
5961
from typing import IO
6062

6163
from qwt.scale_widget import QwtScaleWidget
@@ -1114,6 +1116,80 @@ def set_scales(self, xscale: str, yscale: str) -> None:
11141116
self.set_axis_scale(ay, yscale)
11151117
self.replot()
11161118

1119+
def set_axis_datetime(
1120+
self,
1121+
axis_id: int | str,
1122+
format: str = "%Y-%m-%d %H:%M:%S",
1123+
rotate: float = -45,
1124+
spacing: int = 20,
1125+
) -> None:
1126+
"""Configure an axis to display datetime labels
1127+
1128+
This method sets up an axis to display Unix timestamps as formatted
1129+
date/time strings.
1130+
1131+
Args:
1132+
axis_id: Axis ID (constants.Y_LEFT, constants.X_BOTTOM, ...)
1133+
or string: 'bottom', 'left', 'top' or 'right'
1134+
format: Format string for datetime display (default: "%Y-%m-%d %H:%M:%S").
1135+
Uses Python datetime.strftime() format codes.
1136+
rotate: Rotation angle for labels in degrees (default: -45)
1137+
spacing: Spacing between labels (default: 20)
1138+
1139+
Examples:
1140+
>>> # Enable datetime on x-axis with default format
1141+
>>> plot.set_axis_datetime("bottom")
1142+
1143+
>>> # Enable datetime with time only
1144+
>>> plot.set_axis_datetime("bottom", format="%H:%M:%S")
1145+
1146+
>>> # Enable datetime with date only, no rotation
1147+
>>> plot.set_axis_datetime("bottom", format="%Y-%m-%d", rotate=0)
1148+
"""
1149+
axis_id = self.get_axis_id(axis_id)
1150+
scale_draw = DateTimeScaleDraw(format=format, rotate=rotate, spacing=spacing)
1151+
self.setAxisScaleDraw(axis_id, scale_draw)
1152+
self.replot()
1153+
1154+
def set_axis_limits_from_datetime(
1155+
self,
1156+
axis_id: int | str,
1157+
dt_min: "datetime",
1158+
dt_max: "datetime",
1159+
stepsize: int = 0,
1160+
) -> None:
1161+
"""Set axis limits using datetime objects
1162+
1163+
This is a convenience method to set axis limits for datetime axes without
1164+
manually converting datetime objects to Unix timestamps.
1165+
1166+
Args:
1167+
axis_id: Axis ID (constants.Y_LEFT, constants.X_BOTTOM, ...)
1168+
or string: 'bottom', 'left', 'top' or 'right'
1169+
dt_min: Minimum datetime value
1170+
dt_max: Maximum datetime value
1171+
stepsize: The step size (optional, default=0)
1172+
1173+
Examples:
1174+
>>> from datetime import datetime
1175+
>>> # Set x-axis limits to a specific date range
1176+
>>> dt1 = datetime(2025, 10, 7, 10, 0, 0)
1177+
>>> dt2 = datetime(2025, 10, 7, 18, 0, 0)
1178+
>>> plot.set_axis_limits_from_datetime("bottom", dt1, dt2)
1179+
"""
1180+
from datetime import datetime
1181+
1182+
if not isinstance(dt_min, datetime) or not isinstance(dt_max, datetime):
1183+
raise TypeError("dt_min and dt_max must be datetime objects")
1184+
1185+
# Convert datetime objects to Unix timestamps
1186+
epoch = datetime(1970, 1, 1)
1187+
timestamp_min = (dt_min - epoch).total_seconds()
1188+
timestamp_max = (dt_max - epoch).total_seconds()
1189+
1190+
# Set the axis limits using the timestamps
1191+
self.set_axis_limits(axis_id, timestamp_min, timestamp_max, stepsize)
1192+
11171193
def get_autoscale_margin_percent(self) -> float:
11181194
"""Get autoscale margin percentage
11191195

plotpy/plot/scaledraw.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Scale draw classes for custom axis formatting"""
7+
8+
from __future__ import annotations
9+
10+
from datetime import datetime
11+
12+
from qtpy import QtCore as QC
13+
from qwt import QwtScaleDraw, QwtText
14+
15+
16+
class DateTimeScaleDraw(QwtScaleDraw):
17+
"""Scale draw for datetime axis
18+
19+
This class formats axis labels as date/time strings from Unix timestamps.
20+
21+
Args:
22+
format: Format string for datetime display (default: "%Y-%m-%d %H:%M:%S").
23+
Uses Python datetime.strftime() format codes.
24+
rotate: Rotation angle for labels in degrees (default: -45)
25+
spacing: Spacing between labels (default: 20)
26+
27+
Examples:
28+
>>> # Create a datetime scale with default format
29+
>>> scale = DateTimeScaleDraw()
30+
31+
>>> # Create a datetime scale with custom format (time only)
32+
>>> scale = DateTimeScaleDraw(format="%H:%M:%S")
33+
34+
>>> # Create a datetime scale with date only
35+
>>> scale = DateTimeScaleDraw(format="%Y-%m-%d", rotate=0)
36+
"""
37+
38+
def __init__(
39+
self,
40+
format: str = "%Y-%m-%d %H:%M:%S",
41+
rotate: float = -45,
42+
spacing: int = 20,
43+
):
44+
super().__init__()
45+
self._format = format
46+
self.setLabelRotation(rotate)
47+
self.setLabelAlignment(QC.Qt.AlignHCenter | QC.Qt.AlignVCenter)
48+
self.setSpacing(spacing)
49+
50+
def label(self, value: float) -> QwtText:
51+
"""Convert a timestamp value to a formatted date/time label
52+
53+
Args:
54+
value: Unix timestamp (seconds since epoch)
55+
56+
Returns:
57+
QwtText: Formatted label
58+
"""
59+
try:
60+
dt = datetime.fromtimestamp(value)
61+
return QwtText(dt.strftime(self._format))
62+
except (ValueError, OSError):
63+
# Handle invalid timestamps
64+
return QwtText("")
65+
66+
def get_format(self) -> str:
67+
"""Get the current datetime format string
68+
69+
Returns:
70+
str: Format string
71+
"""
72+
return self._format
73+
74+
def set_format(self, format: str) -> None:
75+
"""Set the datetime format string
76+
77+
Args:
78+
format: Format string for datetime display
79+
"""
80+
self._format = format
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Curve plotting with time axis test"""
7+
8+
# guitest: show
9+
10+
from __future__ import annotations
11+
12+
from datetime import datetime, timedelta
13+
14+
import numpy as np
15+
from guidata.qthelpers import qt_app_context
16+
17+
from plotpy.builder import make
18+
from plotpy.tests import vistools as ptv
19+
20+
21+
def __create_time_data() -> tuple[np.ndarray, np.ndarray]:
22+
"""Create time data"""
23+
# Create a temperature monitoring signal with second-resolution timestamps
24+
base_time = datetime(2025, 10, 7, 10, 0, 0)
25+
timestamps = [base_time + timedelta(seconds=i * 10) for i in range(100)]
26+
27+
# Convert timestamps to numpy array of floats (seconds since epoch)
28+
x = np.array([(ts - datetime(1970, 1, 1)).total_seconds() for ts in timestamps])
29+
30+
# Simulate temperature data with daily variation and noise
31+
hours_elapsed = np.arange(100) * 10 / 3600 # Convert to hours
32+
y = 20 + 5 * np.sin(2 * np.pi * hours_elapsed / 24) + np.random.randn(100) * 0.5
33+
34+
return x, y
35+
36+
37+
def test_plot_time_axis():
38+
"""Test plot time axis"""
39+
with qt_app_context(exec_loop=True):
40+
x, y = __create_time_data()
41+
items = [make.curve(x, y, color="b")]
42+
win = ptv.show_items(
43+
items, plot_type="curve", wintitle=test_plot_time_axis.__doc__
44+
)
45+
plot = win.manager.get_plot()
46+
# Configure x-axis for datetime display
47+
plot.set_axis_datetime("bottom", format="%H:%M:%S")
48+
# Set axis limits using datetime objects (zoom to first half of data)
49+
base_time = datetime(2025, 10, 7, 10, 0, 0)
50+
plot.set_axis_limits_from_datetime(
51+
"bottom",
52+
base_time,
53+
base_time + timedelta(minutes=10),
54+
)
55+
56+
57+
if __name__ == "__main__":
58+
test_plot_time_axis()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Testing datetime axis feature"""
7+
8+
# guitest: show
9+
10+
from datetime import datetime
11+
12+
import numpy as np
13+
from guidata.qthelpers import qt_app_context
14+
15+
from plotpy.builder import make
16+
from plotpy.plot.scaledraw import DateTimeScaleDraw
17+
from plotpy.tests import vistools as ptv
18+
19+
20+
def test_datetime_axis():
21+
"""Testing datetime axis with various formats"""
22+
with qt_app_context(exec_loop=False):
23+
# Create time data
24+
x = np.linspace(0, 86400, 100) # One day in seconds from epoch
25+
y = np.sin(x / 3600 * 2 * np.pi) + np.random.randn(100) * 0.1
26+
27+
items = [make.curve(x, y, color="b")]
28+
win = ptv.show_items(items, plot_type="curve", wintitle="Test datetime axis")
29+
plot = win.manager.get_plot()
30+
31+
# Test 1: Set datetime axis with default format
32+
plot.set_axis_datetime("bottom")
33+
scale_draw = plot.axisScaleDraw(plot.xBottom)
34+
assert isinstance(scale_draw, DateTimeScaleDraw)
35+
assert scale_draw.get_format() == "%Y-%m-%d %H:%M:%S"
36+
37+
# Test 2: Set datetime axis with custom format (time only)
38+
plot.set_axis_datetime("bottom", format="%H:%M:%S")
39+
scale_draw = plot.axisScaleDraw(plot.xBottom)
40+
assert scale_draw.get_format() == "%H:%M:%S"
41+
42+
# Test 3: Set datetime axis with date only
43+
plot.set_axis_datetime("bottom", format="%Y-%m-%d", rotate=0, spacing=10)
44+
scale_draw = plot.axisScaleDraw(plot.xBottom)
45+
assert scale_draw.get_format() == "%Y-%m-%d"
46+
47+
# Test 4: Verify label formatting
48+
timestamp = 86400.0 # 1970-01-02 00:00:00 UTC
49+
label = scale_draw.label(timestamp)
50+
expected = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
51+
assert label.text() == expected
52+
53+
# Test 5: Set axis limits using datetime objects
54+
dt1 = datetime(1970, 1, 1, 6, 0, 0)
55+
dt2 = datetime(1970, 1, 1, 18, 0, 0)
56+
plot.set_axis_limits_from_datetime("bottom", dt1, dt2)
57+
58+
# Verify the limits were set correctly
59+
# Note: autoscale margin might affect the exact values
60+
vmin, vmax = plot.get_axis_limits("bottom")
61+
expected_min = (dt1 - datetime(1970, 1, 1)).total_seconds()
62+
expected_max = (dt2 - datetime(1970, 1, 1)).total_seconds()
63+
# Just verify that limits are in the right order and contain expected range
64+
assert vmin <= expected_min
65+
assert vmax >= expected_max
66+
assert vmax > vmin
67+
68+
print("All datetime axis tests passed!")
69+
70+
71+
if __name__ == "__main__":
72+
test_datetime_axis()

0 commit comments

Comments
 (0)