Coverage for local_installation_linux/mumott/data_handling/data_container.py: 86%
306 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-11-04 08:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2025-11-04 08:14 +0000
1import logging
3import h5py as h5
4import numpy as np
5import os
7from numpy.typing import NDArray
8from scipy.spatial.transform import Rotation
10from mumott.core.deprecation_warning import print_deprecation_warning
11from mumott.core.geometry import Geometry
12from mumott.core.projection_stack import ProjectionStack, Projection
14logger = logging.getLogger(__name__)
16# used to easily keep track of preferred keys
17_preferred_keys = dict(rotations='inner_angle',
18 tilts='outer_angle',
19 offset_j='j_offset',
20 offset_k='k_offset',
21 rot_mat='rotation_matrix')
24def _deprecated_key_warning(deprecated_key: str):
25 """Internal method for deprecation warnings of keys."""
26 if deprecated_key in _preferred_keys:
27 # Only print once.
28 preferred_key = _preferred_keys.pop(deprecated_key)
29 print_deprecation_warning(
30 f'Entry name {deprecated_key} is deprecated. Use {preferred_key} instead.')
33class DataContainer:
35 """
36 Instances of this class represent data read from an input file in a format suitable for further analysis.
37 The two core components are :attr:`geometry` and :attr:`projections`.
38 The latter comprises a list of :class:`Projection <mumott.core.projection_stack.Projection>`
39 instances, each of which corresponds to a single measurement.
41 By default all data is read, which can be rather time consuming and unnecessary in some cases,
42 e.g., when aligning data.
43 In those cases, one can skip loading the actual measurements by setting :attr:`skip_data` to ``True``.
44 The geometry information and supplementary information such as the diode data will still be read.
46 Example
47 -------
48 The following code snippet illustrates the basic use of the :class:`DataContainer` class.
50 First we create a :class:`DataContainer` instance, providing the path to the data file to be read.
52 >>> from mumott.data_handling import DataContainer
53 >>> dc = DataContainer('tests/test_full_circle.h5')
55 One can then print a short summary of the content of the :class:`DataContainer` instance.
57 >>> print(dc)
58 ==========================================================================
59 DataContainer
60 --------------------------------------------------------------------------
61 Corrected for transmission : False
62 ...
64 To access individual measurements we can use the :attr:`projections` attribute.
65 The latter behaves like a list, where the elements of the list are
66 :class:`Projection <mumott.core.projection_stack.Projection>` objects,
67 each of which represents an individual measurement.
68 We can print a summary of the content of the first projection.
70 >>> print(dc.projections[0])
71 --------------------------------------------------------------------------
72 Projection
73 --------------------------------------------------------------------------
74 hash_data : 3f0ba8
75 hash_diode : 808328
76 hash_weights : 088d39
77 rotation : [1. 0. 0.], [ 0. -1. 0.], [ 0. 0. -1.]
78 j_offset : 0.0
79 k_offset : 0.3
80 inner_angle : None
81 outer_angle : None
82 inner_axis : 0.0, 0.0, -1.0
83 outer_axis : 1.0, 0.0, 0.0
84 --------------------------------------------------------------------------
87 Parameters
88 ----------
89 data_path : str, optional
90 Path of the data file relative to the directory of execution.
91 If None, a data container with an empty :attr:`projections`
92 attached will be initialized.
93 data_type : str, optional
94 The type (or format) of the data file. Supported values are
95 ``h5`` (default) for hdf5 format and ``None`` for an empty ``DataContainer``
96 that can be manually populated.
97 skip_data : bool, optional
98 If ``True``, will skip data from individual measurements when loading the file.
99 This will result in a functioning :attr:`geometry` instance as well as
100 :attr:`diode` and :attr:`weights` entries in each projection, but
101 :attr:`data` will be empty.
102 nonfinite_replacement_value : float, optional
103 Value to replace nonfinite values (``np.nan``, ``np.inf``, and ``-np.inf``) with in the
104 data, diode, and weights. If ``None`` (default), an error is raised
105 if any nonfinite values are present in these input fields.
106 """
107 def __init__(self,
108 data_path: str = None,
109 data_type: str = 'h5',
110 skip_data: bool = False,
111 nonfinite_replacement_value: float = None):
112 self._correct_for_transmission_called = False
113 self._projections = ProjectionStack()
114 self._geometry_dictionary = dict()
115 self._skip_data = skip_data
116 self._nonfinite_replacement_value = nonfinite_replacement_value
117 if data_path is not None:
118 if data_type == 'h5': 118 ↛ 121line 118 didn't jump to line 121, because the condition on line 118 was never false
119 self._h5_to_projections(data_path)
120 else:
121 raise ValueError(f'Unknown data_type: {data_type} for'
122 ' load_only_geometry=False.')
124 def _h5_to_projections(self, file_path: str):
125 """
126 Internal method for loading data from hdf5 file.
127 """
128 h5_data = h5.File(file_path, 'r')
129 projections = h5_data['projections']
130 number_of_projections = len(projections)
131 max_shape = (0, 0)
132 inner_axis = np.array((0., 0., -1.))
133 outer_axis = np.array((1., 0., 0.))
134 found_inner_in_base = False
135 found_outer_in_base = False
136 if 'inner_axis' in h5_data:
137 inner_axis = h5_data['inner_axis'][:]
138 logger.info('Inner axis found in dataset base directory. This will override the default.')
139 found_inner_in_base = True
140 if 'outer_axis' in h5_data:
141 outer_axis = h5_data['outer_axis'][:]
142 logger.info('Outer axis found in dataset base directory. This will override the default.')
143 found_outer_in_base = True
145 for i in range(number_of_projections):
146 p = projections[f'{i}']
147 if 'diode' in p: 147 ↛ 145line 147 didn't jump to line 145, because the condition on line 147 was never false
148 max_shape = np.max((max_shape, p['diode'].shape), axis=0)
149 for i in range(number_of_projections):
150 p = projections[f'{i}']
151 if 'diode' in p: 151 ↛ 157line 151 didn't jump to line 157, because the condition on line 151 was never false
152 diode = np.ascontiguousarray(np.copy(p['diode']).astype(np.float64))
153 pad_sequence = np.array(((0, max_shape[0] - diode.shape[0]),
154 (0, max_shape[1] - diode.shape[1]),
155 (0, 0)))
156 diode = np.pad(diode, pad_sequence[:-1])
157 elif 'data' in p:
158 diode = None
159 pad_sequence = np.array(((0, max_shape[0] - p['data'].shape[0]),
160 (0, max_shape[1] - p['data'].shape[1]),
161 (0, 0)))
162 else:
163 pad_sequence = np.zeros((3, 2))
164 if not self._skip_data:
165 data = np.ascontiguousarray(np.copy(p['data']).astype(np.float64))
166 data = np.pad(data, pad_sequence)
167 if 'weights' in p:
168 weights = np.ascontiguousarray(np.copy(p['weights']).astype(np.float64))
169 if weights.ndim == 2 or (weights.ndim == 3 and weights.shape[-1] == 1):
170 weights = weights.reshape(weights.shape[:2])
171 weights = weights[..., np.newaxis] * \
172 np.ones((1, 1, data.shape[-1])).astype(np.float64)
173 weights = np.pad(weights, pad_sequence)
174 else:
175 weights = np.ones_like(data)
176 else:
177 data = None
178 if 'weights' in p: 178 ↛ 182line 178 didn't jump to line 182, because the condition on line 178 was never false
179 weights = np.ascontiguousarray(np.copy(p['weights']).astype(np.float64))
180 weights = np.pad(weights, pad_sequence[:weights.ndim])
181 else:
182 if diode is None:
183 weights = None
184 else:
185 weights = np.ones_like(diode)
186 weights = np.pad(weights, pad_sequence[:weights.ndim])
187 # Look for rotation information and load if available
188 if 'inner_axis' in p:
189 p_inner_axis = p['inner_axis'][:]
190 if found_inner_in_base: 190 ↛ 191line 190 didn't jump to line 191, because the condition on line 190 was never true
191 logger.info(f'Inner axis found in projection {i}. This will override '
192 'the value found in the base directory for all projections '
193 'where it is found.')
194 found_inner_in_base = False
195 # override default only if projection zero
196 elif i == 0: 196 ↛ 203line 196 didn't jump to line 203, because the condition on line 196 was never false
197 logger.info(f'Inner axis found in projection {i}. This will override '
198 'the default value for all projections, if they do not specify '
199 'another axis.')
200 inner_axis = p_inner_axis
201 else:
202 p_inner_axis = inner_axis
203 if 'outer_axis' in p:
204 p_outer_axis = p['outer_axis'][:]
205 if found_outer_in_base: 205 ↛ 206line 205 didn't jump to line 206, because the condition on line 205 was never true
206 logger.info(f'Outer axis found in projection {i}. This will override '
207 'the value found in the base directory for all projections '
208 'where it is found.')
209 found_outer_in_base = False
210 elif i == 0: 210 ↛ 218line 210 didn't jump to line 218, because the condition on line 210 was never false
211 logger.info(f'Inner axis found in projection {i}. This will override '
212 'the default value for all projections, if they do not specify '
213 'another axis.')
214 outer_axis = p_outer_axis
215 else:
216 p_outer_axis = outer_axis
218 inner_angle = None
219 outer_angle = None
221 if 'inner_angle' in p:
222 inner_angle = np.copy(p['inner_angle'][...]).flatten()[0]
223 # if at least one angle exists, assume other is 0 by default.
224 if outer_angle is None: 224 ↛ 231line 224 didn't jump to line 231, because the condition on line 224 was never false
225 outer_angle = 0
226 elif 'rotations' in p:
227 inner_angle = np.copy(p['rotations'][...]).flatten()[0]
228 _deprecated_key_warning('rotations')
229 if outer_angle is None: 229 ↛ 231line 229 didn't jump to line 231, because the condition on line 229 was never false
230 outer_angle = 0
231 if 'outer_angle' in p:
232 outer_angle = np.copy(p['outer_angle'][...]).flatten()[0]
233 if inner_angle is None: 233 ↛ 234line 233 didn't jump to line 234, because the condition on line 233 was never true
234 inner_angle = 0
235 elif 'tilts' in p:
236 outer_angle = np.copy(p['tilts'][...]).flatten()[0]
237 _deprecated_key_warning('tilts')
238 if inner_angle is None: 238 ↛ 239line 238 didn't jump to line 239, because the condition on line 238 was never true
239 inner_angle = 0
241 if 'rotation_matrix' in p:
242 rotation = p['rotation_matrix'][...]
243 if i == 0:
244 logger.info('Rotation matrices were loaded from the input file.')
245 elif 'rot_mat' in p:
246 rotation = p['rot_mat'][...]
247 _deprecated_key_warning('rot_mat')
248 if i == 0:
249 logger.info('Rotation matrices were loaded from the input file.')
250 elif outer_angle is not None: 250 ↛ 259line 250 didn't jump to line 259, because the condition on line 250 was never false
251 R_inner = Rotation.from_rotvec(inner_angle * p_inner_axis).as_matrix()
252 R_outer = Rotation.from_rotvec(outer_angle * p_outer_axis).as_matrix()
253 rotation = R_outer @ R_inner
254 if i == 0:
255 logger.info('Rotation matrix generated from inner and outer angles,'
256 ' along with inner and outer rotation axis vectors.'
257 ' Rotation and tilt angles assumed to be in radians.')
258 else:
259 rotation = np.eye(3)
260 if i == 0:
261 logger.info('No rotation information found.')
263 # default to 0-dim array to simplify subsequent code
264 j_offset = np.array(0)
265 if 'j_offset' in p:
266 j_offset = p['j_offset'][...]
267 elif 'offset_j' in p: 267 ↛ 271line 267 didn't jump to line 271, because the condition on line 267 was never false
268 j_offset = p['offset_j'][...]
269 _deprecated_key_warning('offset_j')
270 # offset will be either numpy/size-0 or size-1 array, ravel and extract.
271 j_offset = j_offset.ravel()[0]
272 j_offset -= pad_sequence[0, 1] * 0.5
274 k_offset = np.array(0)
275 if 'k_offset' in p:
276 k_offset = p['k_offset'][...]
277 elif 'offset_k' in p: 277 ↛ 280line 277 didn't jump to line 280, because the condition on line 277 was never false
278 k_offset = p['offset_k'][...]
279 _deprecated_key_warning('offset_k')
280 k_offset = k_offset.ravel()[0]
281 k_offset -= pad_sequence[1, 1] * 0.5
283 if not self._skip_data:
284 self._handle_nonfinite_values(data)
285 self._handle_nonfinite_values(weights)
286 self._handle_nonfinite_values(diode)
288 projection = Projection(data=data,
289 diode=diode,
290 weights=weights,
291 rotation=rotation,
292 j_offset=j_offset,
293 k_offset=k_offset,
294 outer_angle=outer_angle,
295 inner_angle=inner_angle,
296 inner_axis=p_inner_axis,
297 outer_axis=p_outer_axis
298 )
299 self._projections.append(projection)
300 if not self._skip_data:
301 self._projections.geometry.detector_angles = np.copy(h5_data['detector_angles'])
302 self._estimate_angular_coverage(self._projections.geometry.detector_angles)
303 if 'volume_shape' in h5_data.keys(): 303 ↛ 306line 303 didn't jump to line 306, because the condition on line 303 was never false
304 self._projections.geometry.volume_shape = np.copy(h5_data['volume_shape']).astype(int)
305 else:
306 self._projections.geometry.volume_shape = np.array(max_shape)[[0, 0, 1]]
307 # Load sample geometry information
308 if 'p_direction_0' in h5_data.keys(): # TODO check for orthogonality, normality
309 self._projections.geometry.p_direction_0 = np.copy(h5_data['p_direction_0'][...])
310 self._projections.geometry.j_direction_0 = np.copy(h5_data['j_direction_0'][...])
311 self._projections.geometry.k_direction_0 = np.copy(h5_data['k_direction_0'][...])
312 logger.info('Sample geometry loaded from file.')
313 else:
314 logger.info('No sample geometry information was found. Default mumott geometry assumed.')
316 # Load detector geometry information
317 if 'detector_direction_origin' in h5_data.keys(): # TODO check for orthogonality, normality
318 self._projections.geometry.detector_direction_origin = np.copy(
319 h5_data['detector_direction_origin'][...])
320 self._projections.geometry.detector_direction_positive_90 = np.copy(
321 h5_data['detector_direction_positive_90'][...])
322 logger.info('Detector geometry loaded from file.')
323 else:
324 logger.info('No detector geometry information was found. Default mumott geometry assumed.')
326 # Load scattering angle
327 if 'two_theta' in h5_data:
328 self._projections.geometry.two_theta = np.array(h5_data['two_theta'])
329 logger.info('Scattering angle loaded from data.')
331 def write(self, filename: str) -> None:
332 """
333 Save data and geometry information to a mumott .h5 file.
335 Parameters
336 ----------
337 filename
338 Path of the data file.
340 Raises
341 ------
342 ValueError
343 If the file name does not end on ".h5".
344 """
346 extension = os.path.splitext(filename)[-1]
347 if not extension.lower() in ('.h5', ''):
348 raise ValueError('Only .h5 files supported. Data was not saved.')
349 if extension == '':
350 filename = filename + '.h5'
352 # Alias for line-length limit
353 g = self.geometry
355 # Build file
356 with h5.File(filename, 'w') as file:
358 # Assign global parameters
359 file.create_dataset('detector_angles', data=g.detector_angles)
360 file.create_dataset('volume_shape', data=g.volume_shape)
362 file.create_dataset('p_direction_0', data=g.p_direction_0)
363 file.create_dataset('j_direction_0', data=g.j_direction_0)
364 file.create_dataset('k_direction_0', data=g.k_direction_0)
366 file.create_dataset('detector_direction_origin', data=g.detector_direction_origin)
367 file.create_dataset('detector_direction_positive_90', data=g.detector_direction_positive_90)
369 # Make data group
370 grp = file.create_group('projections')
372 # Loop through projections
373 for ii, (projection, geom_tpl) in enumerate(zip(self.projections, g)):
375 # Make a group for each projection
376 subgrp = grp.create_group(str(ii))
378 # Prpjection data
379 subgrp.create_dataset('data', data=projection.data)
380 subgrp.create_dataset('diode', data=projection.diode)
381 subgrp.create_dataset('weights', data=projection.weights)
382 subgrp.create_dataset('j_offset', data=geom_tpl.j_offset)
383 subgrp.create_dataset('k_offset', data=geom_tpl.k_offset)
384 subgrp.create_dataset('rotation_matrix', data=geom_tpl.rotation)
386 subgrp.create_dataset('inner_axis', data=geom_tpl.inner_axis)
387 subgrp.create_dataset('inner_angle', data=geom_tpl.inner_angle)
388 subgrp.create_dataset('outer_axis', data=geom_tpl.outer_axis)
389 subgrp.create_dataset('outer_angle', data=geom_tpl.outer_angle)
391 def _estimate_angular_coverage(self, detector_angles: list):
392 """Check if full circle appears covered in data or not."""
393 delta = np.abs(detector_angles[0] - detector_angles[-1] % (2 * np.pi))
394 if abs(delta - np.pi) < min(delta, abs(delta - 2 * np.pi)):
395 self.geometry.full_circle_covered = False
396 else:
397 logger.warning('The detector angles appear to cover a full circle. This '
398 'is only expected for WAXS data.')
399 self.geometry.full_circle_covered = True
401 def _handle_nonfinite_values(self, array):
402 """ Internal convenience function for handling nonfinite values. """
403 if np.any(~np.isfinite(array)):
404 if self._nonfinite_replacement_value is not None:
405 np.nan_to_num(array, copy=False, nan=self._nonfinite_replacement_value,
406 posinf=self._nonfinite_replacement_value,
407 neginf=self._nonfinite_replacement_value)
408 else:
409 raise ValueError('Nonfinite values detected in input, which is not permitted by default. '
410 'To permit and replace nonfinite values, please set '
411 'nonfinite_replacement_value to desired value.')
413 def __len__(self) -> int:
414 """
415 Length of the :attr:`projections <mumott.data_handling.projection_stack.ProjectionStack>`
416 attached to this :class:`DataContainer` instance.
417 """
418 return len(self._projections)
420 def append(self, f: Projection) -> None:
421 """
422 Appends a :class:`Projection <mumott.core.projection_stack.Projection>`
423 to the :attr:`projections` attached to this :class:`DataContainer` instance.
424 """
425 self._projections.append(f)
427 @property
428 def projections(self) -> ProjectionStack:
429 """ The projections, containing data and geometry. """
430 return self._projections
432 @property
433 def geometry(self) -> Geometry:
434 """ Container of geometry information. """
435 return self._projections.geometry
437 @property
438 def data(self) -> NDArray[np.float64]:
439 """
440 The data in the :attr:`projections` object
441 attached to this :class:`DataContainer` instance.
442 """
443 return self._projections.data
445 @property
446 def diode(self) -> NDArray[np.float64]:
447 """
448 The diode data in the :attr:`projections` object
449 attached to this :class:`DataContainer` instance.
450 """
451 return self._projections.diode
453 @property
454 def weights(self) -> NDArray[np.float64]:
455 """
456 The weights in the :attr:`projections` object
457 attached to this :class:`DataContainer` instance.
458 """
459 return self._projections.weights
461 def correct_for_transmission(self) -> None:
462 """
463 Applies correction from the input provided in the :attr:`diode
464 <mumott.core.projection_stack.Projection.diode>` field. Should
465 only be used if this correction has *not* been applied yet.
466 """
467 if self._correct_for_transmission_called: 467 ↛ 468line 467 didn't jump to line 468, because the condition on line 467 was never true
468 logger.info(
469 'DataContainer.correct_for_transmission() has been called already.'
470 ' The correction has been applied previously, and the repeat call is ignored.')
471 return
473 data = self._projections.data / self._projections.diode[..., np.newaxis]
475 for i, f in enumerate(self._projections):
476 f.data = data[i]
477 self._correct_for_transmission_called = True
479 def _Rx(self, angle: float) -> NDArray[float]:
480 """ Generate a rotation matrix for rotations around
481 the x-axis, following the convention that vectors
482 have components ordered ``(x, y, z)``.
484 Parameters
485 ----------
486 angle
487 The angle of the rotation.
489 Returns
490 -------
491 R
492 The rotation matrix.
494 Notes
495 -----
496 For a vector ``v`` with shape ``(..., 3)`` and a rotation angle :attr:`angle`,
497 ``np.einsum('ji, ...i', _Rx(angle), v)`` rotates the vector around the
498 ``x``-axis by :attr:`angle`. If the
499 coordinate system is being rotated, then
500 ``np.einsum('ij, ...i', _Rx(angle), v)`` gives the vector in the
501 new coordinate system.
502 """
503 return Rotation.from_euler('X', angle).as_matrix()
505 def _Ry(self, angle: float) -> NDArray[float]:
506 """ Generate a rotation matrix for rotations around
507 the y-axis, following the convention that vectors
508 have components ordered ``(x, y, z)``.
510 Parameters
511 ----------
512 angle
513 The angle of the rotation.
515 Returns
516 -------
517 R
518 The rotation matrix.
520 Notes
521 -----
522 For a vector ``v`` with shape ``(..., 3)`` and a rotation angle ``angle``,
523 ``np.einsum('ji, ...i', _Ry(angle), v)`` rotates the vector around the
524 For a vector ``v`` with shape ``(..., 3)`` and a rotation angle :attr:`angle`,
525 ``np.einsum('ji, ...i', _Ry(angle), v)`` rotates the vector around the
526 ``y``-axis by :attr:`angle`. If the
527 coordinate system is being rotated, then
528 ``np.einsum('ij, ...i', _Ry(angle), v)`` gives the vector in the
529 new coordinate system.
530 """
531 return Rotation.from_euler('Y', angle).as_matrix()
533 def _Rz(self, angle: float) -> NDArray[float]:
534 """ Generate a rotation matrix for rotations around
535 the z-axis, following the convention that vectors
536 have components ordered ``(x, y, z)``.
538 Parameters
539 ----------
540 angle
541 The angle of the rotation.
543 Returns
544 -------
545 R
546 The rotation matrix.
548 Notes
549 -----
550 For a vector ``v`` with shape ``(..., 3)`` and a rotation angle :attr:`angle`,
551 ``np.einsum('ji, ...i', _Rz(angle), v)`` rotates the vector around the
552 ``z``-axis by :attr:`angle`. If the
553 coordinate system is being rotated, then
554 ``np.einsum('ij, ...i', _Rz(angle), v)`` gives the vector in the
555 new coordinate system.
556 """
557 return Rotation.from_euler('Z', angle).as_matrix()
559 def _get_str_representation(self, max_lines=50) -> str:
560 """ Retrieves a string representation of the object with specified
561 maximum number of lines.
563 Parameters
564 ----------
565 max_lines
566 The maximum number of lines to return.
567 """
568 wdt = 74
569 s = []
570 s += ['=' * wdt]
571 s += ['DataContainer'.center(wdt)]
572 s += ['-' * wdt]
573 s += ['{:26} : {}'.format('Corrected for transmission', self._correct_for_transmission_called)]
574 truncated_s = []
575 leave_loop = False
576 while not leave_loop:
577 line = s.pop(0).split('\n')
578 for split_line in line:
579 if split_line != '': 579 ↛ 581line 579 didn't jump to line 581, because the condition on line 579 was never false
580 truncated_s += [split_line]
581 if len(truncated_s) > max_lines - 2:
582 if split_line != '...': 582 ↛ 584line 582 didn't jump to line 584, because the condition on line 582 was never false
583 truncated_s += ['...']
584 if split_line != ('=' * wdt): 584 ↛ 586line 584 didn't jump to line 586, because the condition on line 584 was never false
585 truncated_s += ['=' * wdt]
586 leave_loop = True
587 break
588 if len(s) == 0:
589 leave_loop = True
590 truncated_s += ['=' * wdt]
591 return '\n'.join(truncated_s)
593 def __str__(self) -> str:
594 return self._get_str_representation()
596 def _get_html_representation(self, max_lines=25) -> str:
597 """ Retrieves an html representation of the object with specified
598 maximum number of lines.
600 Parameters
601 ----------
602 max_lines
603 The maximum number of lines to return.
604 """
605 s = []
606 s += ['<h3>DataContainer</h3>']
607 s += ['<table border="1" class="dataframe">']
608 s += ['<thead><tr><th style="text-align: left;">Field</th><th>Size</th></tr></thead>']
609 s += ['<tbody>']
610 s += ['<tr><td style="text-align: left;">Number of projections</td>']
611 s += [f'<td>{len(self._projections)}</td></tr>']
612 s += ['<tr><td style="text-align: left;">Corrected for transmission</td>']
613 s += [f'<td>{self._correct_for_transmission_called}</td></tr>']
614 s += ['</tbody>']
615 s += ['</table>']
616 truncated_s = []
617 line_count = 0
618 leave_loop = False
619 while not leave_loop:
620 line = s.pop(0).split('\n')
621 for split_line in line:
622 truncated_s += [split_line]
623 if '</tr>' in split_line:
624 line_count += 1
625 # Catch if last line had ellipses
626 last_tr = split_line
627 if line_count > max_lines - 1: 627 ↛ 628line 627 didn't jump to line 628, because the condition on line 627 was never true
628 if last_tr != '<tr><td style="text-align: left;">...</td></tr>':
629 truncated_s += ['<tr><td style="text-align: left;">...</td></tr>']
630 truncated_s += ['</tbody>']
631 truncated_s += ['</table>']
632 leave_loop = True
633 break
634 if len(s) == 0:
635 leave_loop = True
636 return '\n'.join(truncated_s)
638 def _repr_html_(self) -> str:
639 return self._get_html_representation()