Skip to content

Commit 3e293e6

Browse files
Fix timeline bar lengths across daylight saving changes
I calculate timeline bar lengths in the same local wall-clock coordinates used by date axes, so timezone-aware endpoints remain aligned across daylight saving changes. I leave the original start column unchanged and cover spring, autumn, UTC, and naive timestamps across dataframe backends. Fixes #4611 Signed-off-by: mottopanikeiku <176798723+mottopanikeiku@users.noreply.github.com>
1 parent 9447f1c commit 3e293e6

3 files changed

Lines changed: 37 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
55
## Unreleased
66

77
### Fixed
8+
- Fix `px.timeline` bars ending an hour early or late when timezone-aware dates span a daylight saving time change [[#4611](https://github.com/plotly/plotly.py/issues/4611)]
89
- Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#5691](https://github.com/plotly/plotly.py/pull/5691)], with thanks to @hb1915 for the contribution!
910
- Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution!
1011
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!

‎plotly/express/_core.py‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2193,11 +2193,18 @@ def process_dataframe_timeline(args):
21932193
"Both x_start and x_end must refer to data convertible to datetimes."
21942194
) from exc
21952195

2196+
# Match the local times displayed on date axes when calculating bar lengths.
2197+
schema = df.schema
2198+
x_start, x_end = [
2199+
nw.col(col).dt.replace_time_zone(None)
2200+
if schema[col] == nw.Datetime and schema[col].time_zone is not None
2201+
else nw.col(col)
2202+
for col in (args["x_start"], args["x_end"])
2203+
]
2204+
21962205
# note that we are not adding any columns to the data frame here, so no risk of overwrite
21972206
args["data_frame"] = df.with_columns(
2198-
(nw.col(args["x_end"]) - nw.col(args["x_start"]))
2199-
.dt.total_milliseconds()
2200-
.alias(args["x_end"])
2207+
(x_end - x_start).dt.total_milliseconds().alias(args["x_end"])
22012208
)
22022209
args["x"] = args["x_end"]
22032210
args["base"] = args["x_start"]

‎tests/test_optional/test_px/test_px_functions.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,32 @@ def test_timeline_cols_already_temporal(constructor, datetime_columns):
680680
assert fig.layout.xaxis.title.text is None
681681

682682

683+
@pytest.mark.parametrize("time_zone", [None, "UTC", "US/Pacific"])
684+
def test_timeline_daylight_saving(constructor, time_zone):
685+
# Regression for https://github.com/plotly/plotly.py/issues/4611
686+
starts = ["2024-03-09", "2024-03-10", "2024-03-11", "2023-11-05"]
687+
finishes = ["2024-03-10", "2024-03-11", "2024-03-12", "2023-11-06"]
688+
df = nw.from_native(
689+
constructor({"Start": starts, "Finish": finishes, "Task": ["Job A"] * 4})
690+
).with_columns(
691+
nw.col("Start", "Finish")
692+
.str.to_datetime(format="%Y-%m-%d")
693+
.dt.replace_time_zone(time_zone)
694+
)
695+
696+
fig = px.timeline(df.to_native(), x_start="Start", x_end="Finish", y="Task")
697+
698+
# Date axes use local wall-clock coordinates: consecutive midnights are
699+
# 24 hours apart even when the elapsed time is 23 or 25 hours.
700+
assert_array_equal(fig.data[0].x, [24 * 60 * 60 * 1000] * 4)
701+
base = np.asarray(fig.data[0].base, dtype="datetime64[ms]")
702+
assert_array_equal(base, np.array(starts, dtype="datetime64[ms]"))
703+
assert_array_equal(
704+
base + fig.data[0].x.astype("timedelta64[ms]"),
705+
np.array(finishes, dtype="datetime64[ms]"),
706+
)
707+
708+
683709
def test_empty_histogram():
684710
"""Empty px.histogram() should not raise, matching scatter/bar/pie behavior.
685711

0 commit comments

Comments
 (0)