datetick 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- datetick/__init__.py +7 -0
- datetick/datetick.py +694 -0
- datetick-0.9.0.dist-info/METADATA +216 -0
- datetick-0.9.0.dist-info/RECORD +9 -0
- datetick-0.9.0.dist-info/WHEEL +5 -0
- datetick-0.9.0.dist-info/licenses/LICENSE.txt +21 -0
- datetick-0.9.0.dist-info/top_level.txt +3 -0
- etc/release.py +93 -0
- test/datetick_test.py +130 -0
datetick/__init__.py
ADDED
datetick/datetick.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
def datetick(*args,
|
|
2
|
+
axes=None,
|
|
3
|
+
set_cb=True,
|
|
4
|
+
adjust_last_xlabel=False,
|
|
5
|
+
adjust_first_xlabel=False,
|
|
6
|
+
adjust_xrange=False,
|
|
7
|
+
adjust_yrange=False,
|
|
8
|
+
debug=False):
|
|
9
|
+
"""
|
|
10
|
+
datetick('x') or datetick('y') formats the major and minor tick labels
|
|
11
|
+
of the current figure.
|
|
12
|
+
|
|
13
|
+
datetick('x', axes=ax) or datetick('y', axes=ax) formats the given axes `ax`.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
--------
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
from datetick import datetick
|
|
20
|
+
d1 = dt.datetime(1900, 1, 2)
|
|
21
|
+
d2 = dt.datetime.fromordinal(10 + dt.datetime.toordinal(d1))
|
|
22
|
+
x = [d1, d2]
|
|
23
|
+
y = [0.0,1.0]
|
|
24
|
+
plt.clf()
|
|
25
|
+
plt.plot(x, y)
|
|
26
|
+
datetick('x')
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Based on spacepy/plot/utils.py on 07/10/2017, but many additions.
|
|
30
|
+
# See also https://github.com/JouleCai/geospacelab/blob/master/geospacelab/visualization/mpl/axis_ticks.py
|
|
31
|
+
|
|
32
|
+
# TODO: Use _numsize() to determine if figure width and height
|
|
33
|
+
# will cause overlap when default number major tick labels is used.
|
|
34
|
+
# TODO: If time[0].day > 28, need to make first tick at time[0].day = 28
|
|
35
|
+
# as needed.
|
|
36
|
+
# TODO: If first data point has fractional seconds, the plot won't have
|
|
37
|
+
# a major x-label right below it. This is due to the fact that
|
|
38
|
+
# MicrosecondLocator() does not take a keyword argument of
|
|
39
|
+
# "bymicroseconds".
|
|
40
|
+
# TODO: Adjust lower and upper limits as in 366*8 span
|
|
41
|
+
|
|
42
|
+
# Get all kwargs passed using locals
|
|
43
|
+
kwargs = {k: v for k, v in locals().items() if k != 'args'}
|
|
44
|
+
|
|
45
|
+
import warnings
|
|
46
|
+
from datetime import datetime
|
|
47
|
+
|
|
48
|
+
import matplotlib.dates as mpld
|
|
49
|
+
|
|
50
|
+
if len(args) == 0:
|
|
51
|
+
dir = 'x'
|
|
52
|
+
else:
|
|
53
|
+
dir = args[0]
|
|
54
|
+
|
|
55
|
+
plt = _get_plt(debug=debug)
|
|
56
|
+
|
|
57
|
+
if 'axes' in kwargs:
|
|
58
|
+
axes = kwargs['axes']
|
|
59
|
+
fig = axes.figure
|
|
60
|
+
else:
|
|
61
|
+
axes = plt.gca()
|
|
62
|
+
fig = plt.gcf()
|
|
63
|
+
|
|
64
|
+
fig.canvas.draw()
|
|
65
|
+
bbox = axes.dataLim
|
|
66
|
+
|
|
67
|
+
if dir == 'x':
|
|
68
|
+
datamin = bbox.x0
|
|
69
|
+
datamax = bbox.x1
|
|
70
|
+
lim = axes.get_xlim()
|
|
71
|
+
ticks = axes.get_xticks()
|
|
72
|
+
else:
|
|
73
|
+
datamin = bbox.y0
|
|
74
|
+
datamax = bbox.y1
|
|
75
|
+
lim = axes.get_ylim()
|
|
76
|
+
ticks = axes.get_yticks()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
mpld.num2date(lim[0])
|
|
80
|
+
except:
|
|
81
|
+
msg = 'Lower axis limit of %f is not a valid Matplotlib datenum' % lim[0]
|
|
82
|
+
raise ValueError(msg)
|
|
83
|
+
try:
|
|
84
|
+
mpld.num2date(lim[1])
|
|
85
|
+
except:
|
|
86
|
+
msg = 'Upper axis limit of %f is not a valid Matplotlib datenum' % lim[1]
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
|
|
89
|
+
# If all values are NaN, datamin = np.inf and datamax = -np.inf.
|
|
90
|
+
msg = "is not a valid Matplotlib datenum. Are all data values NaN? Cannot use datetick()"
|
|
91
|
+
try:
|
|
92
|
+
mpld.num2date(datamin)
|
|
93
|
+
except:
|
|
94
|
+
warnings.warn(f'Minimum value of %f {msg}' % datamin)
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
mpld.num2date(datamax)
|
|
98
|
+
except:
|
|
99
|
+
warnings.warn(f'Maximum value of %f {msg}' % datamax)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if datamin == datamax:
|
|
103
|
+
axes.set_xticks([mpld.num2date(datamin)])
|
|
104
|
+
xticklabel = datetime.strftime(mpld.num2date(datamin),'%Y-%m-%dT%H:%M:%S')
|
|
105
|
+
axes.set_xticklabels([xticklabel])
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Need to document why this was used. It creates
|
|
109
|
+
# problems if labels extend beyond the axis limits.
|
|
110
|
+
#tmin = np.min((lim[0], datamin))
|
|
111
|
+
#tmax = np.max((lim[1], datamax))
|
|
112
|
+
tmin = lim[0]
|
|
113
|
+
tmax = lim[1]
|
|
114
|
+
|
|
115
|
+
tspan = mpld.num2date((tmin, tmax))
|
|
116
|
+
|
|
117
|
+
deltaT = tspan[-1] - tspan[0]
|
|
118
|
+
if debug:
|
|
119
|
+
print("Total seconds: %s" % deltaT.total_seconds())
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
fmt1 is format of the tick labels
|
|
123
|
+
|
|
124
|
+
fmt2 contains additional information that is used for the first tick label
|
|
125
|
+
or when there is a major change. For example, if
|
|
126
|
+
fmt1 = %M:%S and fmt2 = %H,
|
|
127
|
+
the labels will have only minute and hour and the first tick will have a
|
|
128
|
+
label of %M:%S\n%H. If there is a change in hour somewhere on the axis,
|
|
129
|
+
that label will include the new hour.
|
|
130
|
+
|
|
131
|
+
Note that interval=... is specified even when it would seem to be redundant.
|
|
132
|
+
It is needed to workaround the bug discussed at stackoverflow.com/q/31072589
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
Mtick, mtick, fmt1, fmt2 = _locator(axes, deltaT, debug=debug)
|
|
136
|
+
|
|
137
|
+
if debug:
|
|
138
|
+
print(f'{dir} data min: {mpld.num2date(datamin)}')
|
|
139
|
+
print(f'Default {dir}lim[0]: {mpld.num2date(lim[0])}')
|
|
140
|
+
print(f'Default {dir}ticks[0]: {mpld.num2date(ticks[0])}')
|
|
141
|
+
print(f'{dir} data max: {mpld.num2date(datamax)}')
|
|
142
|
+
print(f'Default {dir}lim[-1]: {mpld.num2date(lim[-1])}')
|
|
143
|
+
print(f'Default {dir}ticks[-1]: {mpld.num2date(ticks[-1])}')
|
|
144
|
+
print(f'Default {dir}ticks:')
|
|
145
|
+
for i in range(0,len(ticks)):
|
|
146
|
+
print(f' {mpld.num2date(ticks[i])}')
|
|
147
|
+
|
|
148
|
+
if dir == 'x':
|
|
149
|
+
axes.xaxis.set_major_locator(Mtick)
|
|
150
|
+
axes.xaxis.set_minor_locator(mtick)
|
|
151
|
+
axes.xaxis.set_major_formatter(fmt1)
|
|
152
|
+
if adjust_xrange:
|
|
153
|
+
_adjust_range(dir, fig, axes, datamin, datamax, debug=debug)
|
|
154
|
+
fig.canvas.draw() # Render new labels so updated for next line
|
|
155
|
+
labels = [item.get_text() for item in axes.get_xticklabels()]
|
|
156
|
+
ticks = axes.get_xticks()
|
|
157
|
+
else:
|
|
158
|
+
axes.yaxis.set_major_locator(Mtick)
|
|
159
|
+
axes.yaxis.set_minor_locator(mtick)
|
|
160
|
+
axes.yaxis.set_major_formatter(fmt1)
|
|
161
|
+
if adjust_xrange:
|
|
162
|
+
_adjust_range(dir, fig, axes, datamin, datamax, debug=debug)
|
|
163
|
+
fig.canvas.draw() # Render new labels so updated for next line
|
|
164
|
+
labels = [item.get_text() for item in axes.get_yticklabels()]
|
|
165
|
+
ticks = axes.get_yticks()
|
|
166
|
+
|
|
167
|
+
if debug:
|
|
168
|
+
xl = axes.get_xlim()
|
|
169
|
+
print(f'New {dir}ticks:')
|
|
170
|
+
for i in range(0,len(ticks)):
|
|
171
|
+
note = ''
|
|
172
|
+
if ticks[i] < xl[0] or ticks[i] > xl[1]:
|
|
173
|
+
note = ' (will be clipped by mpl b/c outside of axis limits)'
|
|
174
|
+
print(f' {mpld.num2date(ticks[i])} {note}')
|
|
175
|
+
|
|
176
|
+
if len(labels) == 0:
|
|
177
|
+
if debug:
|
|
178
|
+
print('No labels.')
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if debug:
|
|
182
|
+
print(f'fmt1 {dir}labels:')
|
|
183
|
+
for i in range(0,len(labels)):
|
|
184
|
+
print(f' {labels[i]}')
|
|
185
|
+
|
|
186
|
+
if fmt2 != '':
|
|
187
|
+
labels = _add_fmt2(fmt2, lim, ticks, deltaT, labels, dir, debug=debug)
|
|
188
|
+
|
|
189
|
+
if debug:
|
|
190
|
+
print(f'fmt1 + fmt2 {dir}labels:')
|
|
191
|
+
for i in range(0,len(labels)):
|
|
192
|
+
print(f' {labels[i].replace("\n", "\\n")}')
|
|
193
|
+
|
|
194
|
+
if dir == 'x':
|
|
195
|
+
# Without the set_xticks(), warning is generated:
|
|
196
|
+
# UserWarning: set_ticklabels() should only be used.
|
|
197
|
+
# Additional discussion: https://github.com/matplotlib/matplotlib/issues/18848
|
|
198
|
+
# The correct way to avoid the warning: https://stackoverflow.com/a/69126185
|
|
199
|
+
# Better: Create custom class:
|
|
200
|
+
# https://matplotlib.org/stable/gallery/ticks/date_index_formatter.html
|
|
201
|
+
axes.set_xticks(axes.get_xticks())
|
|
202
|
+
axes.set_xticklabels(labels)
|
|
203
|
+
_adjust_xlabels(axes,
|
|
204
|
+
adjust_first_xlabel=adjust_first_xlabel,
|
|
205
|
+
adjust_last_xlabel=adjust_last_xlabel,
|
|
206
|
+
debug=debug)
|
|
207
|
+
|
|
208
|
+
if dir == 'y':
|
|
209
|
+
axes.set_yticks(axes.get_yticks())
|
|
210
|
+
axes.set_yticks(axes.get_yticks())
|
|
211
|
+
axes.set_yticklabels(labels)
|
|
212
|
+
|
|
213
|
+
# Trigger update of ticks when limits change due to user interaction.
|
|
214
|
+
if 'set_cb':
|
|
215
|
+
def on_xlims_change(ax):
|
|
216
|
+
datetick('x', **{**kwargs, 'set_cb': False})
|
|
217
|
+
|
|
218
|
+
def on_ylims_change(ax):
|
|
219
|
+
datetick('y', **{**kwargs, 'set_cb': False})
|
|
220
|
+
|
|
221
|
+
if dir == 'x':
|
|
222
|
+
axes.callbacks.connect('xlim_changed', on_xlims_change)
|
|
223
|
+
else:
|
|
224
|
+
axes.callbacks.connect('ylim_changed', on_ylims_change)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _adjust_range(dir, fig, axes, datamin, datamax, debug=False):
|
|
228
|
+
if dir == 'x':
|
|
229
|
+
ticks = axes.get_xticks()
|
|
230
|
+
else:
|
|
231
|
+
ticks = axes.get_yticks()
|
|
232
|
+
if len(ticks) >= 2:
|
|
233
|
+
fig.canvas.draw()
|
|
234
|
+
dt = ticks[1] - ticks[0]
|
|
235
|
+
pad = 0.05 * dt
|
|
236
|
+
first_candidates = ticks[ticks <= datamin]
|
|
237
|
+
last_candidates = ticks[ticks >= datamax]
|
|
238
|
+
first = first_candidates[-1] if len(first_candidates) > 0 else ticks[0] - dt
|
|
239
|
+
last = last_candidates[0] if len(last_candidates) > 0 else ticks[-1] + dt
|
|
240
|
+
if dir == 'x':
|
|
241
|
+
axes.set_xlim(first - pad, last + pad)
|
|
242
|
+
else:
|
|
243
|
+
axes.set_ylim(first - pad, last + pad)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _add_fmt2(fmt2, lim, ticks, deltaT, labels, dir, debug=False):
|
|
247
|
+
from datetime import datetime
|
|
248
|
+
import matplotlib.dates as mpld
|
|
249
|
+
|
|
250
|
+
time = mpld.num2date(ticks)
|
|
251
|
+
|
|
252
|
+
nDays = deltaT.days
|
|
253
|
+
nSecs = deltaT.total_seconds()
|
|
254
|
+
|
|
255
|
+
first = 0
|
|
256
|
+
if ticks[0] < lim[0]:
|
|
257
|
+
if debug:
|
|
258
|
+
msg = 'First tick is less than lower axis limit. Applying fmt2 to second tick label.'
|
|
259
|
+
print(msg)
|
|
260
|
+
# Work-around for bug in Matplotlib where left-most tick is less than
|
|
261
|
+
# lower x-limit. Could more than one tick be less than lower x-limit?
|
|
262
|
+
first = 1
|
|
263
|
+
|
|
264
|
+
# Always apply fmt2 to first tick label
|
|
265
|
+
if debug:
|
|
266
|
+
print(f'Applying fmt2 to first tick label at {mpld.num2date(ticks[first])}.')
|
|
267
|
+
labels[first] = '%s\n%s' % (labels[first], datetime.strftime(time[first], fmt2))
|
|
268
|
+
|
|
269
|
+
for i in range(first+1, len(time)):
|
|
270
|
+
# First label will always have fmt1 applied.
|
|
271
|
+
# Modify labels after first under certain conditions.
|
|
272
|
+
modify = False
|
|
273
|
+
|
|
274
|
+
if time[i].year > time[i-1].year:
|
|
275
|
+
modify = True
|
|
276
|
+
if nDays < 60 and time[i].month > time[i-1].month:
|
|
277
|
+
modify = True
|
|
278
|
+
if nDays < 4 and time[i].day > time[i-1].day:
|
|
279
|
+
modify = True
|
|
280
|
+
if nSecs < 60*30 and time[i].hour > time[i-1].hour:
|
|
281
|
+
modify = True
|
|
282
|
+
if nSecs < 1 and time[i].minute > time[i-1].minute:
|
|
283
|
+
modify = True
|
|
284
|
+
if nSecs < 1 and time[i].second > time[i-1].second:
|
|
285
|
+
modify = True
|
|
286
|
+
|
|
287
|
+
if not modify:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if i == first + 1 and dir == 'x':
|
|
291
|
+
# If first two major tick labels have fmt2 applied, the will
|
|
292
|
+
# likely run together. This keeps fmt2 label for second major
|
|
293
|
+
# tick.
|
|
294
|
+
if debug:
|
|
295
|
+
print(f'Removing fmt2 to first tick label at {mpld.num2date(ticks[first])} to avoid overlap with second label.')
|
|
296
|
+
print(f'Applying fmt2 to tick label at {mpld.num2date(ticks[i])}.')
|
|
297
|
+
labels[first] = labels[first].split('\n')[0]
|
|
298
|
+
labels[i] = '%s\n%s' % (labels[i], datetime.strftime(mpld.num2date(ticks[i]), fmt2))
|
|
299
|
+
else:
|
|
300
|
+
if debug:
|
|
301
|
+
print(f'Applying fmt2 to tick label at {mpld.num2date(ticks[i])}.')
|
|
302
|
+
labels[i] = '%s\n%s' % (labels[i], datetime.strftime(mpld.num2date(ticks[i]), fmt2))
|
|
303
|
+
|
|
304
|
+
return labels
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _get_plt(debug=False):
|
|
308
|
+
import warnings
|
|
309
|
+
import matplotlib
|
|
310
|
+
import matplotlib.rcsetup
|
|
311
|
+
|
|
312
|
+
# TODO: Determine earliest version of Matplotlib where the problems
|
|
313
|
+
# addressed here are fixed.
|
|
314
|
+
try: # Available in Matplotlib >= 3.9
|
|
315
|
+
if matplotlib.__version__.split(".") >= (3, 9):
|
|
316
|
+
import matplotlib.backends.backend_registry
|
|
317
|
+
backends = matplotlib.backends.backend_registry.list_builtin()
|
|
318
|
+
else:
|
|
319
|
+
backends = matplotlib.rcsetup.all_backends
|
|
320
|
+
except Exception:
|
|
321
|
+
# In earlier versions of Matplotlib (at least 3.3), the list of backends
|
|
322
|
+
# was not available and get_backend() did not return lower-case names.
|
|
323
|
+
backends = ['Qt5Agg', 'QT4Agg', 'GTKAgg', 'TKAgg', 'WXAgg']
|
|
324
|
+
|
|
325
|
+
if debug:
|
|
326
|
+
print(f'Matplotlib version: {matplotlib.__version__}')
|
|
327
|
+
print(f'Matplotlib backend: {matplotlib.get_backend()}')
|
|
328
|
+
print(f'Available backends: {backends}')
|
|
329
|
+
|
|
330
|
+
if matplotlib.get_backend() == 'MacOSX':
|
|
331
|
+
"""
|
|
332
|
+
With MacOSX backend, draw() does not update the ticks. See warning at
|
|
333
|
+
https://matplotlib.org/3.3.0/tutorials/advanced/blitting.html
|
|
334
|
+
"""
|
|
335
|
+
import sys
|
|
336
|
+
if debug:
|
|
337
|
+
print('Matplotlib backend is MacOSX. Switching backend globally to workaround draw() not updating ticks.')
|
|
338
|
+
if sys.version_info[0:2] < (3, 6):
|
|
339
|
+
# warnings.filterwarnings("ignore", '.*backend.*', category=UserWarning)
|
|
340
|
+
# the above should work and is better because more specific.
|
|
341
|
+
warnings.simplefilter("ignore", category=UserWarning)
|
|
342
|
+
for backend in backends:
|
|
343
|
+
cmd = f"matplotlib.use('{backend}', force=True)"
|
|
344
|
+
try:
|
|
345
|
+
if debug:
|
|
346
|
+
print(f"Trying {cmd}")
|
|
347
|
+
matplotlib.use(backend, force=True)
|
|
348
|
+
import matplotlib.pyplot as plt
|
|
349
|
+
if debug:
|
|
350
|
+
print(" Success.")
|
|
351
|
+
break
|
|
352
|
+
except:
|
|
353
|
+
if debug:
|
|
354
|
+
print(" Failure.")
|
|
355
|
+
continue
|
|
356
|
+
else:
|
|
357
|
+
try:
|
|
358
|
+
import matplotlib.pyplot as plt
|
|
359
|
+
except:
|
|
360
|
+
print('Failed: "import matplotlib.pyplot as plt". Switching backend globally.')
|
|
361
|
+
cmd = f"matplotlib.use('{backends}', force=True)"
|
|
362
|
+
for backend in backends:
|
|
363
|
+
try:
|
|
364
|
+
if debug:
|
|
365
|
+
print(f"Trying {cmd}")
|
|
366
|
+
matplotlib.use(backend, force=True)
|
|
367
|
+
import matplotlib.pyplot as plt
|
|
368
|
+
if debug:
|
|
369
|
+
print(" Success.")
|
|
370
|
+
break
|
|
371
|
+
except:
|
|
372
|
+
if debug:
|
|
373
|
+
print(" Failure.")
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
return plt
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _locator(axes, deltaT, debug=False):
|
|
380
|
+
import matplotlib
|
|
381
|
+
import matplotlib.dates as mpld
|
|
382
|
+
|
|
383
|
+
def _millis(x, pos):
|
|
384
|
+
x = matplotlib.dates.num2date(x)
|
|
385
|
+
label = x.strftime('.%f')
|
|
386
|
+
label = label[0:3]
|
|
387
|
+
#label = label.rstrip(".")
|
|
388
|
+
return label
|
|
389
|
+
|
|
390
|
+
nHours = deltaT.days * 24.0 + deltaT.seconds/3600.0
|
|
391
|
+
if deltaT.total_seconds() < 0.1:
|
|
392
|
+
# < 0.1 second
|
|
393
|
+
Mtick = mpld.MicrosecondLocator(interval=10000)
|
|
394
|
+
mtick = mpld.MicrosecondLocator(interval=2000)
|
|
395
|
+
fmt1 = matplotlib.ticker.FuncFormatter(_millis)
|
|
396
|
+
fmt2 = '%H:%M:%S\n%Y-%m-%d'
|
|
397
|
+
if deltaT.total_seconds() < 0.5:
|
|
398
|
+
# < 0.5 seconds
|
|
399
|
+
# Locators don't locate at this resolution.
|
|
400
|
+
# Need to do this manually. See comment above.
|
|
401
|
+
Mtick = mpld.MicrosecondLocator(interval=50000)
|
|
402
|
+
mtick = mpld.MicrosecondLocator(interval=10000)
|
|
403
|
+
fmt1 = matplotlib.ticker.FuncFormatter(_millis)
|
|
404
|
+
fmt2 = '%H:%M:%S\n%Y-%m-%d'
|
|
405
|
+
if deltaT.total_seconds() < 1:
|
|
406
|
+
# < 1 second
|
|
407
|
+
# https://matplotlib.org/api/dates_api.html#matplotlib.dates.MicrosecondLocator
|
|
408
|
+
# MircosecondLocator() does not have a "bymicrosecond" option. If
|
|
409
|
+
# first point is not at zero microseconds, it won't be labeled.
|
|
410
|
+
Mtick = mpld.MicrosecondLocator(interval=100000)
|
|
411
|
+
mtick = mpld.MicrosecondLocator(interval=20000)
|
|
412
|
+
fmt1 = matplotlib.ticker.FuncFormatter(_millis)
|
|
413
|
+
#fmt1 = mpld.DateFormatter('%M:%S.%f')
|
|
414
|
+
fmt2 = '%H:%M:%S\n%Y-%m-%d'
|
|
415
|
+
elif deltaT.total_seconds() < 5:
|
|
416
|
+
# < 5 seconds
|
|
417
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 1)) )
|
|
418
|
+
mtick = mpld.MicrosecondLocator(interval=200000)
|
|
419
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
420
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
421
|
+
elif deltaT.total_seconds() < 10:
|
|
422
|
+
# < 10 seconds
|
|
423
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 1)) )
|
|
424
|
+
mtick = mpld.MicrosecondLocator(interval=500000)
|
|
425
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
426
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
427
|
+
elif deltaT.total_seconds() < 20:
|
|
428
|
+
# < 20 seconds
|
|
429
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 2)) )
|
|
430
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 1)) )
|
|
431
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
432
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
433
|
+
elif deltaT.total_seconds() < 30:
|
|
434
|
+
# < 30 seconds
|
|
435
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 5)) )
|
|
436
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 1)) )
|
|
437
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
438
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
439
|
+
elif deltaT.total_seconds() < 60:
|
|
440
|
+
# < 1 minute
|
|
441
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 10)) )
|
|
442
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 2)) )
|
|
443
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
444
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
445
|
+
elif deltaT.total_seconds() < 60*2:
|
|
446
|
+
# < 2 minutes
|
|
447
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 20)) )
|
|
448
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 5)) )
|
|
449
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
450
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
451
|
+
elif deltaT.total_seconds() < 60*3:
|
|
452
|
+
# < 3 minutes
|
|
453
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 20)) )
|
|
454
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 5)) )
|
|
455
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
456
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
457
|
+
elif deltaT.total_seconds() < 60*5:
|
|
458
|
+
# < 5 minutes
|
|
459
|
+
Mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 30)) )
|
|
460
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 10)) )
|
|
461
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
462
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
463
|
+
elif deltaT.total_seconds() < 60*10:
|
|
464
|
+
# < 10 minutes
|
|
465
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 1)) )
|
|
466
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 15)) )
|
|
467
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
468
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
469
|
+
elif deltaT.total_seconds() < 60*20:
|
|
470
|
+
# < 20 minutes
|
|
471
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 2)) )
|
|
472
|
+
mtick = mpld.SecondLocator(bysecond=list(range(0, 60, 30)) )
|
|
473
|
+
fmt1 = mpld.DateFormatter('%M:%S')
|
|
474
|
+
fmt2 = '%Y-%m-%dT%H'
|
|
475
|
+
elif deltaT.total_seconds() < 60*30:
|
|
476
|
+
# < 30 minutes
|
|
477
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 5)) )
|
|
478
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 1)) )
|
|
479
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
480
|
+
fmt2 = '%Y-%m-%d'
|
|
481
|
+
elif deltaT.total_seconds() < 60*60:
|
|
482
|
+
# < 60 minutes
|
|
483
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 10)) )
|
|
484
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 2)) )
|
|
485
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
486
|
+
fmt2 = '%Y-%m-%d'
|
|
487
|
+
elif nHours < 2:
|
|
488
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 15)) )
|
|
489
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 5)) )
|
|
490
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
491
|
+
fmt2 = '%Y-%m-%d'
|
|
492
|
+
elif nHours < 4:
|
|
493
|
+
Mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 20)) )
|
|
494
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 5)) )
|
|
495
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
496
|
+
fmt2 = '%Y-%m-%d'
|
|
497
|
+
elif nHours < 6:
|
|
498
|
+
Mtick = mpld.HourLocator(byhour=list(range(0,24,1)) )
|
|
499
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 10)) )
|
|
500
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
501
|
+
fmt2 = '%Y-%m-%d'
|
|
502
|
+
elif nHours < 12:
|
|
503
|
+
Mtick = mpld.HourLocator(byhour=list(range(0,24,2)) )
|
|
504
|
+
mtick = mpld.MinuteLocator(byminute=list(range(0, 60, 30)) )
|
|
505
|
+
fmt1 = mpld.DateFormatter('%H:%M')
|
|
506
|
+
fmt2 = '%Y-%m-%d'
|
|
507
|
+
elif nHours < 24:
|
|
508
|
+
# < 1 day
|
|
509
|
+
Mtick = mpld.HourLocator(byhour=list(range(0, 24, 3)) )
|
|
510
|
+
mtick = mpld.HourLocator(byhour=list(range(0, 24, 1)) )
|
|
511
|
+
fmt1 = mpld.DateFormatter('%H')
|
|
512
|
+
fmt2 = '%Y-%m-%d'
|
|
513
|
+
elif nHours < 48:
|
|
514
|
+
# < 2 days
|
|
515
|
+
Mtick = mpld.HourLocator(byhour=list(range(0, 24, 4)) )
|
|
516
|
+
mtick = mpld.HourLocator(byhour=list(range(0, 24, 2)) )
|
|
517
|
+
fmt1 = mpld.DateFormatter('%H')
|
|
518
|
+
fmt2 = '%Y-%m-%d'
|
|
519
|
+
elif nHours < 72:
|
|
520
|
+
# < 3 days
|
|
521
|
+
Mtick = mpld.HourLocator(byhour = list(range(0, 24, 6)))
|
|
522
|
+
mtick = mpld.HourLocator(byhour = list(range(0, 24, 3)))
|
|
523
|
+
fmt1 = mpld.DateFormatter('%H')
|
|
524
|
+
fmt2 = '%Y-%m-%d'
|
|
525
|
+
elif nHours < 96:
|
|
526
|
+
# < 4 days
|
|
527
|
+
Mtick = mpld.HourLocator(byhour = list(range(0, 24, 12)))
|
|
528
|
+
mtick = mpld.HourLocator(byhour = list(range(0, 24, 3)))
|
|
529
|
+
fmt1 = mpld.DateFormatter('%H')
|
|
530
|
+
fmt2 = '%Y-%m-%d'
|
|
531
|
+
elif deltaT.days < 8:
|
|
532
|
+
Mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 1)))
|
|
533
|
+
mtick = mpld.HourLocator(byhour=list(range(0, 24, 4)))
|
|
534
|
+
fmt1 = mpld.DateFormatter('%d')
|
|
535
|
+
fmt2 = '%Y-%m'
|
|
536
|
+
elif deltaT.days < 16:
|
|
537
|
+
Mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 1)))
|
|
538
|
+
mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 1)))
|
|
539
|
+
fmt1 = mpld.DateFormatter('%d')
|
|
540
|
+
fmt2 = '%Y-%m'
|
|
541
|
+
elif deltaT.days < 32:
|
|
542
|
+
Mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 4)))
|
|
543
|
+
mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 1)))
|
|
544
|
+
fmt1 = mpld.DateFormatter('%d')
|
|
545
|
+
fmt2 = '%Y-%m'
|
|
546
|
+
elif deltaT.days < 60:
|
|
547
|
+
Mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 7)))
|
|
548
|
+
mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 1)))
|
|
549
|
+
fmt1 = mpld.DateFormatter('%d')
|
|
550
|
+
fmt2 = '%Y-%m'
|
|
551
|
+
elif deltaT.days < 183:
|
|
552
|
+
Mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 1)))
|
|
553
|
+
mtick = mpld.DayLocator(bymonthday=list(range(1, 32, 7)))
|
|
554
|
+
fmt1 = mpld.DateFormatter('%m')
|
|
555
|
+
fmt2 = '%Y'
|
|
556
|
+
elif deltaT.days < 367:
|
|
557
|
+
Mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 1)))
|
|
558
|
+
mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 1)))
|
|
559
|
+
fmt1 = mpld.DateFormatter('%m')
|
|
560
|
+
fmt2 = '%Y'
|
|
561
|
+
elif deltaT.days < 366*2:
|
|
562
|
+
Mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 2)))
|
|
563
|
+
mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 1)))
|
|
564
|
+
fmt1 = mpld.DateFormatter('%m')
|
|
565
|
+
fmt2 = '%Y'
|
|
566
|
+
elif deltaT.days < 366*8:
|
|
567
|
+
Mtick = mpld.YearLocator(1)
|
|
568
|
+
mtick = mpld.MonthLocator(bymonth=list(range(1, 13, 4)))
|
|
569
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
570
|
+
fmt2 = ''
|
|
571
|
+
elif deltaT.days < 366*15:
|
|
572
|
+
to = axes.lines[0].get_xdata()[0]
|
|
573
|
+
tf = axes.lines[0].get_xdata()[-1]
|
|
574
|
+
# Ideally would set byyear=list(range(to.year, tf.year,2)) but
|
|
575
|
+
# byyear is not a kwarg. Would need to something like
|
|
576
|
+
# https://stackoverflow.com/questions/48428729/matplotlib-dates-yearlocator-with-odd-intervals
|
|
577
|
+
Mtick = mpld.YearLocator(1)
|
|
578
|
+
mtick = mpld.YearLocator(1)
|
|
579
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
580
|
+
fmt2 = ''
|
|
581
|
+
if False:
|
|
582
|
+
xl = axes.get_xlim()
|
|
583
|
+
a = mpld.num2date(xl[0])
|
|
584
|
+
print(a)
|
|
585
|
+
import pdb;pdb.set_trace()
|
|
586
|
+
a = mpld.date2num(a.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
587
|
+
b = mpld.num2date(xl[1])
|
|
588
|
+
b = mpld.date2num(b.replace(year=(b.year+1), month=1, day=1, hour=0, minute=0, second=0, microsecond=0))
|
|
589
|
+
axes.set_xlim([a, b])
|
|
590
|
+
elif deltaT.days < 366*40:
|
|
591
|
+
Mtick = mpld.YearLocator(5)
|
|
592
|
+
mtick = mpld.YearLocator(1)
|
|
593
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
594
|
+
fmt2 = ''
|
|
595
|
+
elif deltaT.days < 366*100:
|
|
596
|
+
Mtick = mpld.YearLocator(10)
|
|
597
|
+
mtick = mpld.YearLocator(2)
|
|
598
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
599
|
+
fmt2 = ''
|
|
600
|
+
elif deltaT.days < 366*200:
|
|
601
|
+
Mtick = mpld.YearLocator(20)
|
|
602
|
+
mtick = mpld.YearLocator(5)
|
|
603
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
604
|
+
fmt2 = ''
|
|
605
|
+
else:
|
|
606
|
+
Mtick = mpld.YearLocator(50)
|
|
607
|
+
mtick = mpld.YearLocator(10)
|
|
608
|
+
fmt1 = mpld.DateFormatter('%Y')
|
|
609
|
+
fmt2 = ''
|
|
610
|
+
|
|
611
|
+
return Mtick, mtick, fmt1, fmt2
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _adjust_xlabels(axes, adjust_first_xlabel=False, adjust_last_xlabel=False, debug=False):
|
|
615
|
+
xticklabels = axes.get_xticklabels()
|
|
616
|
+
lastlabel_text = xticklabels[-1].get_text()
|
|
617
|
+
firstlabel_text = xticklabels[0].get_text()
|
|
618
|
+
adjusted = False
|
|
619
|
+
|
|
620
|
+
if adjust_first_xlabel and '\n' in firstlabel_text:
|
|
621
|
+
if len(firstlabel_text.split('\n')[-1]) > 7:
|
|
622
|
+
if debug:
|
|
623
|
+
print(f'Adjusting first x-label: "{firstlabel_text.replace("\n", "\\n")}"')
|
|
624
|
+
adjusted = True
|
|
625
|
+
# If fmt1 in first label longer than YYYY-MM, set justification to left.
|
|
626
|
+
xticklabels[0].set_ha('left')
|
|
627
|
+
# Shift to right by 1/2 width of fmt1 in first label.
|
|
628
|
+
first_fmt1 = firstlabel_text.split('\n')[0]
|
|
629
|
+
offset = _numsize(axes, first_fmt1, +1, debug=debug)
|
|
630
|
+
xticklabels[0].set_transform(xticklabels[0].get_transform() - offset)
|
|
631
|
+
|
|
632
|
+
if adjust_last_xlabel and '\n' in lastlabel_text:
|
|
633
|
+
if len(lastlabel_text.split('\n')[-1]) > 7:
|
|
634
|
+
if debug:
|
|
635
|
+
print(f'Adjusting last x-label: "{lastlabel_text.replace("\n", "\\n")}"')
|
|
636
|
+
adjusted = True
|
|
637
|
+
# If fmt1 iin last label longer than YYYY-MM, set justification to right.
|
|
638
|
+
xticklabels[-1].set_ha('right')
|
|
639
|
+
# Shift to left by 1/2 width of fmt1 in last label.
|
|
640
|
+
last_fmt1 = lastlabel_text.split('\n')[0]
|
|
641
|
+
offset = _numsize(axes, last_fmt1, -1, debug=debug)
|
|
642
|
+
xticklabels[-1].set_transform(xticklabels[-1].get_transform() - offset)
|
|
643
|
+
|
|
644
|
+
if adjusted:
|
|
645
|
+
# Make all labels without newline slightly smaller than default fontsize
|
|
646
|
+
# so it is clearer that fmt2 applies to larger number.
|
|
647
|
+
if debug:
|
|
648
|
+
print('Adjusting font size of x-labels without newline')
|
|
649
|
+
for label in xticklabels:
|
|
650
|
+
if '\n' not in label.get_text():
|
|
651
|
+
label.set_fontsize(label.get_fontsize()*0.85)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _numsize(ax, num, sign, debug=False):
|
|
655
|
+
'''Returns (width, height) of str(num) in pixels.
|
|
656
|
+
|
|
657
|
+
If ax is given, measures against that axes' renderer and DPI (correct).
|
|
658
|
+
Otherwise creates a temporary figure using rcParams figure.dpi.
|
|
659
|
+
'''
|
|
660
|
+
import re
|
|
661
|
+
import matplotlib
|
|
662
|
+
import matplotlib.figure
|
|
663
|
+
import matplotlib.backends.backend_agg
|
|
664
|
+
|
|
665
|
+
num = str(num)
|
|
666
|
+
|
|
667
|
+
#dpi = ax.figure.get_dpi() if ax is not None else matplotlib.rcParams['figure.dpi']
|
|
668
|
+
dpi = 72 # Why not use above dpi? On OS-X when dpi = 200 is returned, offset is wrong.
|
|
669
|
+
fig = matplotlib.figure.Figure(dpi=dpi)
|
|
670
|
+
canvas = matplotlib.backends.backend_agg.FigureCanvasAgg(fig)
|
|
671
|
+
ax_tmp = fig.add_subplot(111)
|
|
672
|
+
renderer = canvas.get_renderer()
|
|
673
|
+
fontsize = matplotlib.rcParams['xtick.labelsize']
|
|
674
|
+
t = ax_tmp.text(0.5, 0.5, num, fontsize=fontsize)
|
|
675
|
+
|
|
676
|
+
w, h, d = renderer.get_text_width_height_descent(num, t.get_fontproperties(), ismath=False)
|
|
677
|
+
dpi = ax.figure.get_dpi() if ax is not None else matplotlib.rcParams['figure.dpi']
|
|
678
|
+
delta = w/len(num)
|
|
679
|
+
# It seems like sign should not be needed, but does not work if
|
|
680
|
+
# + offset instead of - offset is used code that uses offset.
|
|
681
|
+
offset = matplotlib.transforms.ScaledTranslation(sign*delta/dpi, 0, ax.figure.dpi_scale_trans)
|
|
682
|
+
|
|
683
|
+
if debug:
|
|
684
|
+
print('_numsize():')
|
|
685
|
+
print(f' num = "{num}"')
|
|
686
|
+
print(f' fontsize = {fontsize}')
|
|
687
|
+
print(f' dpi = {dpi}')
|
|
688
|
+
print(f' width = {w}')
|
|
689
|
+
print(f' height = {h}')
|
|
690
|
+
print(f' descent = {d}')
|
|
691
|
+
print(f' delta = {delta}')
|
|
692
|
+
print(f' offset = {re.sub(r"\n\s+", "", str(offset))}')
|
|
693
|
+
|
|
694
|
+
return offset
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datetick
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Sensible date tick locator for matplotlib
|
|
5
|
+
Author: Brendan Gallagher
|
|
6
|
+
Author-email: Bob Weigel <rweigel@gmu.edu>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, http://pypi.python.org/pypi/datetick/
|
|
9
|
+
Requires-Python: >=3.7
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE.txt
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: python-dateutil
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# datetick
|
|
18
|
+
|
|
19
|
+
Sensible numeric time and date tick labels for Matplotlib
|
|
20
|
+
|
|
21
|
+
# Motivation
|
|
22
|
+
|
|
23
|
+
Matplotlib's default time tick labels are often poor, and adjusting them requires using [locators and formatters](https://matplotlib.org/stable/api/ticker_api.html) on an ad-hoc basis. In addition, the interfaces for locators and formatters complex and non-intuitive and require study and experimentation.
|
|
24
|
+
|
|
25
|
+
`datetick()` contains logic for locators and formatters that apply to plots with arbitrary time ranges. One only needs to add the command `datetick()` after the usual Matplotlib `plt.plot(...)` command to have sensible and useable time tick labels.
|
|
26
|
+
|
|
27
|
+
# Usage
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
import datetime as dt
|
|
31
|
+
import matplotlib.pyplot as plt
|
|
32
|
+
from datetick import datetick
|
|
33
|
+
|
|
34
|
+
dt1 = dt.datetime(2011, 1, 2)
|
|
35
|
+
dt2 = dt1 + dt.timedelta(days=1, hours=1, minutes=1)
|
|
36
|
+
|
|
37
|
+
plt.plot([dt1, dt2], [0.0,1.0])
|
|
38
|
+
datetick()
|
|
39
|
+
plt.show()
|
|
40
|
+
# or
|
|
41
|
+
# datetick('x') (use 'y' if y variable is a time)
|
|
42
|
+
# or
|
|
43
|
+
# datetick('x', axes=plt.gca())
|
|
44
|
+
# or
|
|
45
|
+
# fig, axes = plt.subplots(2)
|
|
46
|
+
# plt.plot([dt1, dt2], [0.0, 1.0])
|
|
47
|
+
# datetick('x', axes=axes[0])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
# Comparison to default Matplotlib
|
|
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
|
+
|
|
122
|
+

|
|
123
|
+
|
|
124
|
+

|
|
125
|
+
|
|
126
|
+

|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+

|
|
131
|
+
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+

|
|
135
|
+
|
|
136
|
+

|
|
137
|
+
|
|
138
|
+

|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
144
|
+

|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+

|
|
149
|
+
|
|
150
|
+

|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+

|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+

|
|
161
|
+
|
|
162
|
+

|
|
163
|
+
|
|
164
|
+

|
|
165
|
+
|
|
166
|
+

|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+

|
|
171
|
+
|
|
172
|
+

|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
176
|
+

|
|
177
|
+
|
|
178
|
+

|
|
179
|
+
|
|
180
|
+

|
|
181
|
+
|
|
182
|
+

|
|
183
|
+
|
|
184
|
+

|
|
185
|
+
|
|
186
|
+

|
|
187
|
+
|
|
188
|
+

|
|
189
|
+
|
|
190
|
+

|
|
191
|
+
|
|
192
|
+

|
|
193
|
+
|
|
194
|
+

|
|
195
|
+
|
|
196
|
+

|
|
197
|
+
|
|
198
|
+

|
|
199
|
+
|
|
200
|
+

|
|
201
|
+
|
|
202
|
+

|
|
203
|
+
|
|
204
|
+

|
|
205
|
+
|
|
206
|
+

|
|
207
|
+
|
|
208
|
+

|
|
209
|
+
|
|
210
|
+

|
|
211
|
+
|
|
212
|
+

|
|
213
|
+
|
|
214
|
+

|
|
215
|
+
|
|
216
|
+

|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
datetick/__init__.py,sha256=Q_1eN-e8ewVpwdzExrVDYCIVwFJ4Le_AueDsdjoLZBA,200
|
|
2
|
+
datetick/datetick.py,sha256=g_ejtah9mvsctC9n27-jOczeCwvSClNhNRv8pIoJims,24860
|
|
3
|
+
datetick-0.9.0.dist-info/licenses/LICENSE.txt,sha256=UyVxGUH2kA6ZPDrjS3dtWGk5cUnsLnY7OlNSabyeCYA,1087
|
|
4
|
+
etc/release.py,sha256=-ApOOmegDxt4QUBc_ATr6YgHdZufM5ecXyj_eOn41U8,2737
|
|
5
|
+
test/datetick_test.py,sha256=GHHeMA7SUNCkBupOfI56dSE-l3BfoybMTokBQzf39LU,3412
|
|
6
|
+
datetick-0.9.0.dist-info/METADATA,sha256=DQXL_ajkgncha8NfMfGdAYfIHFe-P1NKOnvy2MJEdzA,10368
|
|
7
|
+
datetick-0.9.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
datetick-0.9.0.dist-info/top_level.txt,sha256=1ZRIT2tCJ-2z6LU-rrxHoJe2JmzN2uIN4WCZYolEAxg,18
|
|
9
|
+
datetick-0.9.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026- Bob Weigel, Brendan Gallagher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
etc/release.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage:
|
|
3
|
+
python release.py
|
|
4
|
+
[path/to/pyproject.toml]
|
|
5
|
+
[--pypi-config-file path/to/pypirc]
|
|
6
|
+
|
|
7
|
+
Creates GitHub and PyPi releases with the same version number in pyproject.toml.
|
|
8
|
+
|
|
9
|
+
If path/to/pyproject.toml is not specified, it will look for it in the parent
|
|
10
|
+
directory of this script.
|
|
11
|
+
|
|
12
|
+
Requires:
|
|
13
|
+
A GitHub token file at ~/git/admin/etc/github_token (plain text, one line).
|
|
14
|
+
A PyPI config file at ~/git/admin/etc/pypirc
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import subprocess
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import tomllib
|
|
23
|
+
except ImportError:
|
|
24
|
+
try:
|
|
25
|
+
import tomli as tomllib
|
|
26
|
+
except ImportError:
|
|
27
|
+
cmd_list = [sys.executable, '-m', 'pip', 'install', 'tomli']
|
|
28
|
+
print("Executing:", " ".join(cmd_list))
|
|
29
|
+
subprocess.run(cmd_list, check=True)
|
|
30
|
+
import tomli as tomllib
|
|
31
|
+
|
|
32
|
+
def _install_deps():
|
|
33
|
+
try:
|
|
34
|
+
import build # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
cmd_list = [sys.executable, '-m', 'pip', 'install', 'build']
|
|
37
|
+
print("Executing:", " ".join(cmd_list))
|
|
38
|
+
subprocess.run(cmd_list, check=True)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import twine # noqa: F401
|
|
42
|
+
except ImportError:
|
|
43
|
+
cmd_list = [sys.executable, '-m', 'pip', 'install', 'twine']
|
|
44
|
+
print("Executing:", " ".join(cmd_list))
|
|
45
|
+
subprocess.run(cmd_list, check=True)
|
|
46
|
+
|
|
47
|
+
def main(pypi_config_file):
|
|
48
|
+
if len(sys.argv) > 1:
|
|
49
|
+
toml_path = sys.argv[1]
|
|
50
|
+
else:
|
|
51
|
+
toml_path = os.path.join(os.path.dirname(__file__), '..', 'pyproject.toml')
|
|
52
|
+
toml_path = os.path.abspath(toml_path)
|
|
53
|
+
|
|
54
|
+
if not os.path.exists(toml_path):
|
|
55
|
+
print(f"Could not find pyproject.toml at {toml_path}")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
with open(toml_path, 'rb') as f:
|
|
59
|
+
data = tomllib.load(f)
|
|
60
|
+
|
|
61
|
+
version = data.get('project', {}).get('version')
|
|
62
|
+
if not version:
|
|
63
|
+
print(f"Could not find [project] version in {toml_path}")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
print(f"Creating release {version} on GitHub and PyPi")
|
|
67
|
+
|
|
68
|
+
# Check if gh CLI is installed
|
|
69
|
+
try:
|
|
70
|
+
subprocess.run(['gh', '--version'], check=True, stdout=subprocess.DEVNULL)
|
|
71
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
72
|
+
print("Error: gh CLI is not installed. Please install it from https://cli.github.com/")
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
cmd_list = ['gh', 'release', 'create', version, '-t', version, '-n', f'Release {version}']
|
|
76
|
+
#print("Executing:", " ".join(cmd_list))
|
|
77
|
+
#subprocess.run(cmd_list, check=True)
|
|
78
|
+
|
|
79
|
+
# Build package
|
|
80
|
+
cmd_list = [sys.executable, '-m', 'build']
|
|
81
|
+
print("Executing:", " ".join(cmd_list))
|
|
82
|
+
subprocess.run(cmd_list, check=True)
|
|
83
|
+
exit()
|
|
84
|
+
|
|
85
|
+
# Upload package to PyPi
|
|
86
|
+
cmd_list = ['twine', 'upload', '--config-file', pypi_config_file, 'dist/*']
|
|
87
|
+
subprocess.run(cmd_list, check=True)
|
|
88
|
+
|
|
89
|
+
if __name__ == '__main__':
|
|
90
|
+
_install_deps()
|
|
91
|
+
pypi_config_path = os.path.expanduser('~/git/admin/etc')
|
|
92
|
+
pypi_config_file = os.path.join(pypi_config_path, 'pypirc')
|
|
93
|
+
main(pypi_config_file)
|
test/datetick_test.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Create plots with varying time ranges.
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
import dateutil.parser
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
|
|
8
|
+
from datetick import datetick
|
|
9
|
+
|
|
10
|
+
debug = False
|
|
11
|
+
|
|
12
|
+
def plot(ds1, ds2, **kwargs):
|
|
13
|
+
_, axes = plt.subplots(2, figsize=(8, 2))
|
|
14
|
+
plt.subplots_adjust(hspace=1.0)
|
|
15
|
+
|
|
16
|
+
dt1 = dateutil.parser.parse(ds1)
|
|
17
|
+
dt2 = dateutil.parser.parse(ds2)
|
|
18
|
+
x = [dt1, dt2]
|
|
19
|
+
xt = x[0] + (x[1] - x[0])/2
|
|
20
|
+
y = [0.0,0.0]
|
|
21
|
+
|
|
22
|
+
bbox = dict(boxstyle='round,pad=0.3', facecolor='white', edgecolor='gray', alpha=0.8)
|
|
23
|
+
|
|
24
|
+
axes[0].set_title(ds1 + ' - ' + ds2, fontfamily='monospace')
|
|
25
|
+
axes[0].plot(x, y, '*')
|
|
26
|
+
axes[0].text(xt, 0.00, 'matplotlib', ha='center', bbox=bbox)
|
|
27
|
+
|
|
28
|
+
text = 'datetick'
|
|
29
|
+
if kwargs:
|
|
30
|
+
bbox['facecolor'] = 'lightblue'
|
|
31
|
+
for key, value in kwargs.items():
|
|
32
|
+
text += f'\n{key}={value}'
|
|
33
|
+
axes[1].plot(x, y, '*')
|
|
34
|
+
axes[1].text(xt, 0.00, text, ha='center', bbox=bbox)
|
|
35
|
+
datetick('x', axes=axes[1], debug=debug, **kwargs)
|
|
36
|
+
|
|
37
|
+
for axis in axes:
|
|
38
|
+
axis.grid()
|
|
39
|
+
axis.spines[['top', 'right', 'left']].set_visible(False)
|
|
40
|
+
axis.yaxis.set_visible(False)
|
|
41
|
+
|
|
42
|
+
file = _savefig(ds1, ds2)
|
|
43
|
+
|
|
44
|
+
return file
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _savefig(ds1, ds2):
|
|
48
|
+
ds1 = ds1.replace(":","").replace("-","").replace("T","").replace("Z","")
|
|
49
|
+
ds2 = ds2.replace(":","").replace("-","").replace("T","").replace("Z","")
|
|
50
|
+
|
|
51
|
+
file = f'{_script_dir()}/{_mpl_version()}/{ds1}-{ds2}.svg'
|
|
52
|
+
if file in files:
|
|
53
|
+
v = 2
|
|
54
|
+
while file in files:
|
|
55
|
+
file = f'{_script_dir()}/{_mpl_version()}/{ds1}-{ds2}_{v}.svg'
|
|
56
|
+
v += 1
|
|
57
|
+
|
|
58
|
+
print("Writing", file)
|
|
59
|
+
dirname = os.path.dirname(file)
|
|
60
|
+
if not os.path.exists(dirname):
|
|
61
|
+
os.makedirs(dirname, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
plt.savefig(file, bbox_inches='tight')
|
|
64
|
+
plt.close()
|
|
65
|
+
|
|
66
|
+
return file
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _append_to_readme(files):
|
|
70
|
+
|
|
71
|
+
readme = 'README.md' # Repo README
|
|
72
|
+
readme = os.path.join(_script_dir(), "..", readme)
|
|
73
|
+
|
|
74
|
+
with open(readme, 'r+') as file:
|
|
75
|
+
lines = file.readlines()
|
|
76
|
+
|
|
77
|
+
index = next(i for i, line in enumerate(lines) if "Comparison to default Matplotlib" in line)
|
|
78
|
+
del lines[index+1:]
|
|
79
|
+
|
|
80
|
+
image_links = []
|
|
81
|
+
for file in files:
|
|
82
|
+
# Make path relative to README
|
|
83
|
+
file = os.path.relpath(file, os.path.dirname(readme))
|
|
84
|
+
image_links.append(f'')
|
|
85
|
+
|
|
86
|
+
lines.append("\n" + "\n\n".join(image_links))
|
|
87
|
+
|
|
88
|
+
print(f"Updating {readme} with {len(files)} images")
|
|
89
|
+
with open(readme, 'w') as file:
|
|
90
|
+
file.writelines(lines)
|
|
91
|
+
|
|
92
|
+
def _create_subdir_readme(files):
|
|
93
|
+
# Create README in mpl subdir
|
|
94
|
+
image_links = []
|
|
95
|
+
for file in files:
|
|
96
|
+
# Make path relative to README
|
|
97
|
+
file = os.path.basename(file)
|
|
98
|
+
image_links.append(f'')
|
|
99
|
+
|
|
100
|
+
readme = os.path.join(_script_dir(), _mpl_version(), 'README.md')
|
|
101
|
+
print(f"Writing {readme} with {len(files)} images")
|
|
102
|
+
with open(readme, 'w') as file:
|
|
103
|
+
file.writelines("\n" + "\n\n".join(image_links))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _mpl_version():
|
|
107
|
+
return f"mpl-{plt.matplotlib.__version__}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _script_dir():
|
|
111
|
+
return os.path.dirname(os.path.realpath(__file__))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if False:
|
|
115
|
+
plot('2001-01-01T00:00:58Z','2001-01-01T00:01:18Z', adjust_xrange=True)
|
|
116
|
+
#plot('2001-01-01T00:00:00Z','2001-01-02T23:00:00Z', adjust_first_xlabel=True)
|
|
117
|
+
exit()
|
|
118
|
+
|
|
119
|
+
test_file = os.path.join(_script_dir(), 'datetick_test.yaml')
|
|
120
|
+
with open(test_file, 'r') as file:
|
|
121
|
+
tests = yaml.safe_load(file)
|
|
122
|
+
|
|
123
|
+
files = []
|
|
124
|
+
for test in tests['main']:
|
|
125
|
+
kwargs = test[2] if len(test) > 2 else {}
|
|
126
|
+
file = plot(test[0], test[1], **kwargs)
|
|
127
|
+
files.append(file)
|
|
128
|
+
|
|
129
|
+
_append_to_readme(files)
|
|
130
|
+
_create_subdir_readme(files)
|