phasorpy 0.7__cp314-cp314-win_amd64.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.
phasorpy/io/_simfcs.py ADDED
@@ -0,0 +1,652 @@
1
+ """Read and write SimFCS file formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ 'phasor_from_simfcs_referenced',
7
+ 'phasor_to_simfcs_referenced',
8
+ 'signal_from_b64',
9
+ 'signal_from_bh',
10
+ 'signal_from_bhz',
11
+ 'signal_from_fbd',
12
+ 'signal_from_z64',
13
+ ]
14
+
15
+ import math
16
+ import os
17
+ import struct
18
+ import zlib
19
+ from typing import TYPE_CHECKING
20
+
21
+ from .._utils import chunk_iter, parse_harmonic, xarray_metadata
22
+ from ..phasor import phasor_from_polar, phasor_to_polar
23
+
24
+ if TYPE_CHECKING:
25
+ from .._typing import (
26
+ Any,
27
+ ArrayLike,
28
+ DataArray,
29
+ Literal,
30
+ NDArray,
31
+ PathLike,
32
+ Sequence,
33
+ )
34
+
35
+ import numpy
36
+
37
+
38
+ def phasor_to_simfcs_referenced(
39
+ filename: str | PathLike[Any],
40
+ mean: ArrayLike,
41
+ real: ArrayLike,
42
+ imag: ArrayLike,
43
+ /,
44
+ *,
45
+ size: int | None = None,
46
+ dims: Sequence[str] | None = None,
47
+ ) -> None:
48
+ """Write phasor coordinate images to SimFCS referenced R64 file(s).
49
+
50
+ SimFCS referenced R64 files store square-shaped (commonly 256x256)
51
+ images of the average intensity, and the calibrated phasor coordinates
52
+ (encoded as phase and modulation) of two harmonics as ZIP-compressed,
53
+ single precision floating point arrays.
54
+ The file format does not support any metadata.
55
+
56
+ Images with more than two dimensions or larger than square size are
57
+ chunked to square-sized images and saved to separate files with
58
+ a name pattern, for example, "filename_T099_Y256_X000.r64".
59
+ Images or chunks with less than two dimensions or smaller than square size
60
+ are padded with NaN values.
61
+
62
+ Parameters
63
+ ----------
64
+ filename : str or Path
65
+ Name of SimFCS referenced R64 file to write.
66
+ The file extension must be ``.r64``.
67
+ mean : array_like
68
+ Average intensity image.
69
+ real : array_like
70
+ Image of real component of calibrated phasor coordinates.
71
+ Multiple harmonics, if any, must be in the first dimension.
72
+ Harmonics must be starting at and increasing by one.
73
+ imag : array_like
74
+ Image of imaginary component of calibrated phasor coordinates.
75
+ Multiple harmonics, if any, must be in the first dimension.
76
+ Harmonics must be starting at and increasing by one.
77
+ size : int, optional
78
+ Size of X and Y dimensions of square-sized images stored in file.
79
+ Must be in range [4, 65535].
80
+ By default, ``size = min(256, max(4, sizey, sizex))``.
81
+ dims : sequence of str, optional
82
+ Character codes for `mean` dimensions used to format file names.
83
+ Only used when chunking multi-dimensional data into multiple files.
84
+
85
+ See Also
86
+ --------
87
+ phasorpy.io.phasor_from_simfcs_referenced
88
+
89
+ Examples
90
+ --------
91
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32)
92
+ >>> phasor_to_simfcs_referenced('_phasorpy.r64', mean, real, imag)
93
+
94
+ """
95
+ filename, ext = os.path.splitext(filename)
96
+ if ext.lower() != '.r64':
97
+ raise ValueError(f'file extension {ext} != .r64')
98
+
99
+ # TODO: delay conversions to numpy arrays to inner loop
100
+ mean = numpy.asarray(mean, numpy.float32)
101
+ phi, mod = phasor_to_polar(real, imag, dtype=numpy.float32)
102
+ del real
103
+ del imag
104
+ phi = numpy.rad2deg(phi)
105
+
106
+ if phi.shape != mod.shape:
107
+ raise ValueError(f'{phi.shape=} != {mod.shape=}')
108
+ if mean.shape != phi.shape[-mean.ndim :]:
109
+ raise ValueError(f'{mean.shape=} != {phi.shape[-mean.ndim:]=}')
110
+ if phi.ndim == mean.ndim:
111
+ phi = phi.reshape(1, *phi.shape)
112
+ mod = mod.reshape(1, *mod.shape)
113
+ nharmonic = phi.shape[0]
114
+
115
+ if mean.ndim < 2:
116
+ # not an image
117
+ mean = mean.reshape(1, -1)
118
+ phi = phi.reshape(nharmonic, 1, -1)
119
+ mod = mod.reshape(nharmonic, 1, -1)
120
+
121
+ # TODO: investigate actual size and harmonics limits of SimFCS
122
+ sizey, sizex = mean.shape[-2:]
123
+ if size is None:
124
+ size = min(256, max(4, sizey, sizex))
125
+ elif not 4 <= size <= 65535:
126
+ raise ValueError(f'{size=} out of range [4, 65535]')
127
+
128
+ harmonics_per_file = 2 # TODO: make this a parameter?
129
+ chunk_shape = tuple(
130
+ [max(harmonics_per_file, 2)] + ([1] * (phi.ndim - 3)) + [size, size]
131
+ )
132
+ multi_file = any(i / j > 1 for i, j in zip(phi.shape, chunk_shape))
133
+
134
+ if dims is not None and len(dims) == phi.ndim - 1:
135
+ dims = tuple(dims)
136
+ dims = ('h' if dims[0].islower() else 'H',) + dims
137
+
138
+ chunk = numpy.empty((size, size), dtype=numpy.float32)
139
+
140
+ def rawdata_append(
141
+ rawdata: list[bytes], a: NDArray[Any] | None = None
142
+ ) -> None:
143
+ if a is None:
144
+ chunk[:] = numpy.nan
145
+ rawdata.append(chunk.tobytes())
146
+ else:
147
+ sizey, sizex = a.shape[-2:]
148
+ if sizey == size and sizex == size:
149
+ rawdata.append(a.tobytes())
150
+ elif sizey <= size and sizex <= size:
151
+ chunk[:sizey, :sizex] = a[..., :sizey, :sizex]
152
+ chunk[:sizey, sizex:] = numpy.nan
153
+ chunk[sizey:, :] = numpy.nan
154
+ rawdata.append(chunk.tobytes())
155
+ else:
156
+ raise RuntimeError # should not be reached
157
+
158
+ for index, label, _ in chunk_iter(
159
+ phi.shape, chunk_shape, dims, squeeze=False, use_index=True
160
+ ):
161
+ rawdata = [struct.pack('I', size)]
162
+ rawdata_append(rawdata, mean[index[1:]])
163
+ phi_ = phi[index]
164
+ mod_ = mod[index]
165
+ for i in range(phi_.shape[0]):
166
+ rawdata_append(rawdata, phi_[i])
167
+ rawdata_append(rawdata, mod_[i])
168
+ if phi_.shape[0] == 1:
169
+ rawdata_append(rawdata)
170
+ rawdata_append(rawdata)
171
+
172
+ if not multi_file:
173
+ label = ''
174
+ with open(filename + label + ext, 'wb') as fh:
175
+ fh.write(zlib.compress(b''.join(rawdata)))
176
+
177
+
178
+ def phasor_from_simfcs_referenced(
179
+ filename: str | PathLike[Any],
180
+ /,
181
+ *,
182
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
183
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
184
+ """Return phasor coordinates and metadata from SimFCS REF or R64 file.
185
+
186
+ SimFCS referenced REF and R64 files contain square-shaped phasor
187
+ coordinate images (encoded as phase and modulation) for two harmonics.
188
+ Phasor coordinates from lifetime-resolved signals are calibrated.
189
+ Variants of referenced files (RE<n>) written by other software may
190
+ contain up to eight harmonics and may be uncalibrated.
191
+
192
+ Parameters
193
+ ----------
194
+ filename : str or Path
195
+ Name of SimFCS REF, R64, or RE<n> file to read.
196
+ harmonic : int, sequence of int, or 'all', optional
197
+ Harmonic(s) to include in returned phasor coordinates.
198
+ By default, only the first harmonic is returned.
199
+ If 'all', return all available harmonics.
200
+ If int or sequence, return specified harmonic(s).
201
+
202
+ Returns
203
+ -------
204
+ mean : ndarray
205
+ Average intensity image.
206
+ real : ndarray
207
+ Image of real component of phasor coordinates.
208
+ Multiple harmonics, if any, are in the first axis.
209
+ imag : ndarray
210
+ Image of imaginary component of phasor coordinates.
211
+ Multiple harmonics, if any, are in the first axis.
212
+ attrs : dict
213
+ Select metadata containing:
214
+
215
+ - ``'dims'`` (tuple of str):
216
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
217
+
218
+ Raises
219
+ ------
220
+ lfdfiles.LfdfileError
221
+ File is not a SimFCS REF, R64, or RE<n> file.
222
+
223
+ See Also
224
+ --------
225
+ phasorpy.io.phasor_to_simfcs_referenced
226
+
227
+ Notes
228
+ -----
229
+ The implementation for reading R64 files is based on the
230
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
231
+
232
+ Examples
233
+ --------
234
+ >>> phasor_to_simfcs_referenced(
235
+ ... '_phasorpy.r64', *numpy.random.rand(3, 32, 32)
236
+ ... )
237
+ >>> mean, real, imag, _ = phasor_from_simfcs_referenced('_phasorpy.r64')
238
+ >>> mean
239
+ array([[...]], dtype=float32)
240
+
241
+ """
242
+ ext = os.path.splitext(filename)[-1].lower()
243
+ if ext == '.r64':
244
+ import lfdfiles
245
+
246
+ with lfdfiles.SimfcsR64(filename) as r64:
247
+ data = r64.asarray()
248
+ elif ext.startswith('.re') and len(ext) == 4:
249
+ if ext[-1] == 'f':
250
+ num_images = 5
251
+ elif ext[-1].isdigit():
252
+ # non-SimFCS referenced files containing other number of harmonics
253
+ num_images = int(ext[-1]) * 2 + 1
254
+ else:
255
+ raise ValueError(
256
+ f'file extension must be .ref, .r64, or .re<n>, not {ext!r}'
257
+ )
258
+ size = os.path.getsize(filename)
259
+ if (
260
+ size > 4294967295
261
+ or size % (num_images * 4)
262
+ or not math.sqrt(size // (num_images * 4)).is_integer()
263
+ ):
264
+ raise ValueError(f'{filename!r} is not a valid referenced file')
265
+ size = int(math.sqrt(size // (num_images * 4)))
266
+ data = numpy.fromfile(filename, dtype='<f4').reshape((-1, size, size))
267
+ else:
268
+ raise ValueError(
269
+ f'file extension must be .ref, .r64, or .re<n>, not {ext!r}'
270
+ )
271
+
272
+ harmonic, keep_harmonic_dim = parse_harmonic(harmonic, data.shape[0] // 2)
273
+
274
+ mean = data[0].copy()
275
+ real = numpy.empty((len(harmonic),) + mean.shape, numpy.float32)
276
+ imag = numpy.empty_like(real)
277
+ for i, h in enumerate(harmonic):
278
+ h = (h - 1) * 2 + 1
279
+ re, im = phasor_from_polar(numpy.deg2rad(data[h]), data[h + 1])
280
+ real[i] = re
281
+ imag[i] = im
282
+ if not keep_harmonic_dim:
283
+ real = real.reshape(mean.shape)
284
+ imag = imag.reshape(mean.shape)
285
+
286
+ return mean, real, imag, {'dims': ('Y', 'X')}
287
+
288
+
289
+ def signal_from_fbd(
290
+ filename: str | PathLike[Any],
291
+ /,
292
+ *,
293
+ frame: int | None = None,
294
+ channel: int | None = 0,
295
+ keepdims: bool = False,
296
+ laser_factor: float = -1.0,
297
+ ) -> DataArray:
298
+ """Return phase histogram and metadata from FLIMbox FBD file.
299
+
300
+ FDB files contain encoded cross-correlation phase histograms from
301
+ digital frequency-domain measurements using a FLIMbox device.
302
+ The encoding scheme depends on the FLIMbox device's firmware.
303
+ The FBD file format is undocumented.
304
+
305
+ This function may fail to produce expected results when files use unknown
306
+ firmware, do not contain image scans, settings were recorded incorrectly,
307
+ scanner and FLIMbox frequencies were out of sync, or scanner settings were
308
+ changed during acquisition.
309
+
310
+ Parameters
311
+ ----------
312
+ filename : str or Path
313
+ Name of FLIMbox FBD file to read.
314
+ frame : int, optional
315
+ If None (default), return all frames.
316
+ If < 0, integrate time axis, else return specified frame.
317
+ channel : int or None, optional
318
+ Index of channel to return.
319
+ By default, return the first channel.
320
+ If None, return all channels.
321
+ keepdims : bool, optional, default: False
322
+ If true, return reduced axes as size-one dimensions.
323
+ laser_factor : float, optional
324
+ Factor to correct dwell_time/laser_frequency.
325
+
326
+ Returns
327
+ -------
328
+ xarray.DataArray
329
+ Phase histogram with :ref:`axes codes <axes>` ``'TCYXH'`` and
330
+ type ``uint16``:
331
+
332
+ - ``coords['H']``: cross-correlation phases in radians.
333
+ - ``attrs['frequency']``: repetition frequency in MHz.
334
+ - ``attrs['harmonic']``: harmonic contained in phase histogram.
335
+ - ``attrs['flimbox_header']``: FBD binary header, if any.
336
+ - ``attrs['flimbox_firmware']``: FLIMbox firmware settings, if any.
337
+ - ``attrs['flimbox_settings']``: Settings from FBS XML, if any.
338
+
339
+ Raises
340
+ ------
341
+ lfdfiles.LfdFileError
342
+ File is not a FLIMbox FBD file.
343
+
344
+ Notes
345
+ -----
346
+ The implementation is based on the
347
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
348
+
349
+ Examples
350
+ --------
351
+ >>> signal = signal_from_fbd(
352
+ ... fetch('convallaria_000$EI0S.fbd')
353
+ ... ) # doctest: +SKIP
354
+ >>> signal.values # doctest: +SKIP
355
+ array(...)
356
+ >>> signal.dtype # doctest: +SKIP
357
+ dtype('uint16')
358
+ >>> signal.shape # doctest: +SKIP
359
+ (9, 256, 256, 64)
360
+ >>> signal.dims # doctest: +SKIP
361
+ ('T', 'Y', 'X', 'H')
362
+ >>> signal.coords['H'].data # doctest: +SKIP
363
+ array([0, ..., 6.185])
364
+ >>> signal.attrs['frequency'] # doctest: +SKIP
365
+ 40.0
366
+
367
+ """
368
+ import lfdfiles
369
+
370
+ integrate_frames = 0 if frame is None or frame >= 0 else 1
371
+
372
+ with lfdfiles.FlimboxFbd(filename, laser_factor=laser_factor) as fbd:
373
+ data = fbd.asimage(None, None, integrate_frames=integrate_frames)
374
+ if integrate_frames:
375
+ frame = None
376
+ copy = False
377
+ axes = 'TCYXH'
378
+ if channel is None:
379
+ if not keepdims and data.shape[1] == 1:
380
+ data = data[:, 0]
381
+ axes = 'TYXH'
382
+ else:
383
+ if channel < 0 or channel >= data.shape[1]:
384
+ raise IndexError(f'{channel=} out of bounds')
385
+ if keepdims:
386
+ data = data[:, channel : channel + 1]
387
+ else:
388
+ data = data[:, channel]
389
+ axes = 'TYXH'
390
+ copy = True
391
+ if frame is None:
392
+ if not keepdims and data.shape[0] == 1:
393
+ data = data[0]
394
+ axes = axes[1:]
395
+ else:
396
+ if frame < 0 or frame >= data.shape[0]:
397
+ raise IndexError(f'{frame=} out of bounds')
398
+ if keepdims:
399
+ data = data[frame : frame + 1]
400
+ else:
401
+ data = data[frame]
402
+ axes = axes[1:]
403
+ copy = True
404
+ if copy:
405
+ data = data.copy()
406
+ # TODO: return arrival window indices or micro-times as H coords?
407
+ phases = numpy.linspace(
408
+ 0.0, numpy.pi * 2, data.shape[-1], endpoint=False
409
+ )
410
+ metadata = xarray_metadata(axes, data.shape, H=phases)
411
+ attrs = metadata['attrs']
412
+ attrs['frequency'] = fbd.laser_frequency * 1e-6
413
+ attrs['harmonic'] = fbd.harmonics
414
+ if fbd.header is not None:
415
+ attrs['flimbox_header'] = fbd.header
416
+ if fbd.fbf is not None:
417
+ attrs['flimbox_firmware'] = fbd.fbf
418
+ if fbd.fbs is not None:
419
+ attrs['flimbox_settings'] = fbd.fbs
420
+
421
+ from xarray import DataArray
422
+
423
+ return DataArray(data, **metadata)
424
+
425
+
426
+ def signal_from_b64(
427
+ filename: str | PathLike[Any],
428
+ /,
429
+ ) -> DataArray:
430
+ """Return intensity image and metadata from SimFCS B64 file.
431
+
432
+ B64 files contain one or more square intensity image(s), a carpet
433
+ of lines, or a stream of intensity data. B64 files contain no metadata.
434
+
435
+ Parameters
436
+ ----------
437
+ filename : str or Path
438
+ Name of SimFCS B64 file to read.
439
+
440
+ Returns
441
+ -------
442
+ xarray.DataArray
443
+ Intensity image of type ``int16``.
444
+
445
+ Raises
446
+ ------
447
+ lfdfiles.LfdFileError
448
+ File is not a SimFCS B64 file.
449
+ ValueError
450
+ File does not contain an image stack.
451
+
452
+ Notes
453
+ -----
454
+ The implementation is based on the
455
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
456
+
457
+ Examples
458
+ --------
459
+ >>> signal = signal_from_b64(fetch('simfcs.b64'))
460
+ >>> signal.values
461
+ array(...)
462
+ >>> signal.dtype
463
+ dtype('int16')
464
+ >>> signal.shape
465
+ (22, 1024, 1024)
466
+ >>> signal.dtype
467
+ dtype('int16')
468
+ >>> signal.dims
469
+ ('I', 'Y', 'X')
470
+
471
+ """
472
+ import lfdfiles
473
+
474
+ with lfdfiles.SimfcsB64(filename) as b64:
475
+ data = b64.asarray()
476
+ if data.ndim != 3:
477
+ raise ValueError(
478
+ f'{os.path.basename(filename)!r} '
479
+ 'does not contain an image stack'
480
+ )
481
+ metadata = xarray_metadata(b64.axes, data.shape, filename)
482
+
483
+ from xarray import DataArray
484
+
485
+ return DataArray(data, **metadata)
486
+
487
+
488
+ def signal_from_z64(
489
+ filename: str | PathLike[Any],
490
+ /,
491
+ ) -> DataArray:
492
+ """Return image stack and metadata from SimFCS Z64 file.
493
+
494
+ Z64 files commonly contain stacks of square images, such as intensity
495
+ volumes or TCSPC histograms. Z64 files contain no metadata.
496
+
497
+ Parameters
498
+ ----------
499
+ filename : str or Path
500
+ Name of SimFCS Z64 file to read.
501
+
502
+ Returns
503
+ -------
504
+ xarray.DataArray
505
+ Image stack of type ``float32``.
506
+
507
+ Raises
508
+ ------
509
+ lfdfiles.LfdFileError
510
+ File is not a SimFCS Z64 file.
511
+
512
+ Notes
513
+ -----
514
+ The implementation is based on the
515
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
516
+
517
+ Examples
518
+ --------
519
+ >>> signal = signal_from_z64(fetch('simfcs.z64'))
520
+ >>> signal.values
521
+ array(...)
522
+ >>> signal.dtype
523
+ dtype('float32')
524
+ >>> signal.shape
525
+ (256, 256, 256)
526
+ >>> signal.dims
527
+ ('Q', 'Y', 'X')
528
+
529
+ """
530
+ import lfdfiles
531
+
532
+ with lfdfiles.SimfcsZ64(filename) as z64:
533
+ data = z64.asarray()
534
+ metadata = xarray_metadata(z64.axes, data.shape, filename)
535
+
536
+ from xarray import DataArray
537
+
538
+ return DataArray(data, **metadata)
539
+
540
+
541
+ def signal_from_bh(
542
+ filename: str | PathLike[Any],
543
+ /,
544
+ ) -> DataArray:
545
+ """Return TCSPC histogram and metadata from SimFCS B&H file.
546
+
547
+ B&H files contain TCSPC histograms acquired from Becker & Hickl
548
+ cards, or converted from other data sources. B&H files contain no metadata.
549
+
550
+ Parameters
551
+ ----------
552
+ filename : str or Path
553
+ Name of SimFCS B&H file to read.
554
+
555
+ Returns
556
+ -------
557
+ xarray.DataArray
558
+ TCSPC histogram with :ref:`axes codes <axes>` ``'HYX'``,
559
+ shape ``(256, 256, 256)``, and type ``float32``.
560
+
561
+ Raises
562
+ ------
563
+ lfdfiles.LfdFileError
564
+ File is not a SimFCS B&H file.
565
+
566
+ Notes
567
+ -----
568
+ The implementation is based on the
569
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
570
+
571
+ Examples
572
+ --------
573
+ >>> signal = signal_from_bh(fetch('simfcs.b&h'))
574
+ >>> signal.values
575
+ array(...)
576
+ >>> signal.dtype
577
+ dtype('float32')
578
+ >>> signal.shape
579
+ (256, 256, 256)
580
+ >>> signal.dims
581
+ ('H', 'Y', 'X')
582
+
583
+ """
584
+ import lfdfiles
585
+
586
+ with lfdfiles.SimfcsBh(filename) as bnh:
587
+ assert bnh.axes is not None
588
+ data = bnh.asarray()
589
+ metadata = xarray_metadata(
590
+ bnh.axes.replace('Q', 'H'), data.shape, filename
591
+ )
592
+
593
+ from xarray import DataArray
594
+
595
+ return DataArray(data, **metadata)
596
+
597
+
598
+ def signal_from_bhz(
599
+ filename: str | PathLike[Any],
600
+ /,
601
+ ) -> DataArray:
602
+ """Return TCSPC histogram and metadata from SimFCS BHZ file.
603
+
604
+ BHZ files contain TCSPC histograms acquired from Becker & Hickl
605
+ cards, or converted from other data sources. BHZ files contain no metadata.
606
+
607
+ Parameters
608
+ ----------
609
+ filename : str or Path
610
+ Name of SimFCS BHZ file to read.
611
+
612
+ Returns
613
+ -------
614
+ xarray.DataArray
615
+ TCSPC histogram with :ref:`axes codes <axes>` ``'HYX'``,
616
+ shape ``(256, 256, 256)``, and type ``float32``.
617
+
618
+ Raises
619
+ ------
620
+ lfdfiles.LfdFileError
621
+ File is not a SimFCS BHZ file.
622
+
623
+ Notes
624
+ -----
625
+ The implementation is based on the
626
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
627
+
628
+ Examples
629
+ --------
630
+ >>> signal = signal_from_bhz(fetch('simfcs.bhz'))
631
+ >>> signal.values
632
+ array(...)
633
+ >>> signal.dtype
634
+ dtype('float32')
635
+ >>> signal.shape
636
+ (256, 256, 256)
637
+ >>> signal.dims
638
+ ('H', 'Y', 'X')
639
+
640
+ """
641
+ import lfdfiles
642
+
643
+ with lfdfiles.SimfcsBhz(filename) as bhz:
644
+ assert bhz.axes is not None
645
+ data = bhz.asarray()
646
+ metadata = xarray_metadata(
647
+ bhz.axes.replace('Q', 'H'), data.shape, filename
648
+ )
649
+
650
+ from xarray import DataArray
651
+
652
+ return DataArray(data, **metadata)