fullplot 0.1.0__py3-none-any.whl → 2.0.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.
fullplot/__init__.py CHANGED
@@ -1,25 +1,16 @@
1
- """FullPlot public package interface.
2
-
3
- FullPlot is a small engineering-data plotting package centered on HDF5 files,
4
- reusable one-dimensional ``Trace`` objects, shared ``TimeAxis`` objects, and
5
- rectangular-grid map generation. Most users import the package as ``fullplot``
6
- or ``fplt`` and use the names re-exported here instead of importing from the
7
- internal modules directly.
8
- """
1
+ """FullPlot public package interface."""
9
2
 
10
3
  from fullplot.cli import main
11
4
  from fullplot.fullplot import (
12
5
  AmbiguousDatasetError,
13
6
  DatasetNotFoundError,
14
7
  FullPlotError,
15
- H5File,
16
8
  PlotDataError,
17
9
  TimeAxis,
18
10
  Trace,
19
11
  VALID_TRACE_ROLES,
20
12
  list,
21
13
  map,
22
- open,
23
14
  plot,
24
15
  read,
25
16
  show,
@@ -29,6 +20,7 @@ from fullplot.fullplot import (
29
20
  values,
30
21
  write_traces,
31
22
  )
23
+ from fullplot.linearization import H5File, Linearization, LinearizationError, open
32
24
  from fullplot.maps import (
33
25
  Axis,
34
26
  FullPlotMapError,
@@ -37,7 +29,7 @@ from fullplot.maps import (
37
29
  generate_map,
38
30
  )
39
31
 
40
- __version__ = "0.1.0"
32
+ __version__ = "2.0.0"
41
33
 
42
34
  __all__ = [
43
35
  "__version__",
@@ -45,6 +37,8 @@ __all__ = [
45
37
  "DatasetNotFoundError",
46
38
  "AmbiguousDatasetError",
47
39
  "PlotDataError",
40
+ "LinearizationError",
41
+ "Linearization",
48
42
  "TimeAxis",
49
43
  "Trace",
50
44
  "VALID_TRACE_ROLES",
fullplot/fullplot.py CHANGED
@@ -199,28 +199,11 @@ def _line_style_for_role(role: str, theme: str, color, linewidth: float) -> dict
199
199
 
200
200
  @dataclass
201
201
  class TimeAxis:
202
- """Shared, shiftable time basis for related traces.
202
+ """Shared, shiftable and windowable time basis for related traces.
203
203
 
204
- ``TimeAxis`` stores the original time samples from an HDF5 dataset or a user
205
- supplied array and applies a mutable zero-time offset when values are read.
206
- A single ``TimeAxis`` can be shared by many ``Trace`` objects, which makes it
207
- convenient to align test data and simulation data without copying every
208
- trace.
209
-
210
- Parameters
211
- ----------
212
- raw:
213
- One-dimensional, strictly increasing time values in the source data
214
- units. FullPlot normally uses seconds, but no unit conversion is
215
- performed.
216
- name:
217
- Human-readable axis name, usually the HDF5 dataset basename such as
218
- ``"time"``.
219
- zero:
220
- Raw data value that should be displayed as zero. The shifted values are
221
- always ``raw - zero``.
222
- attrs:
223
- Optional metadata copied from the HDF5 dataset or supplied by the user.
204
+ Several ``Trace`` objects can reference one ``TimeAxis``. Shifting or
205
+ windowing the axis updates all of those traces together without changing the
206
+ original source arrays.
224
207
  """
225
208
 
226
209
  raw: np.ndarray
@@ -240,35 +223,83 @@ class TimeAxis:
240
223
  self.name = str(self.name)
241
224
  self.zero = float(self.zero)
242
225
  self.attrs = {} if self.attrs is None else dict(self.attrs)
226
+ self._window_start: float | None = None
227
+ self._window_stop: float | None = None
243
228
 
244
229
  @property
245
- def values(self) -> np.ndarray:
246
- """Return the current shifted time values as ``raw - zero``."""
230
+ def full_values(self) -> np.ndarray:
231
+ """Return all shifted time values before any active window is applied."""
247
232
  return self.raw - self.zero
248
233
 
234
+ def _selection_for(self, start: float | None, stop: float | None) -> slice:
235
+ if start is not None:
236
+ start = float(start)
237
+ if stop is not None:
238
+ stop = float(stop)
239
+ if start is not None and stop is not None and start > stop:
240
+ raise ValueError("TimeAxis window start cannot be greater than stop.")
241
+
242
+ values = self.full_values
243
+ tol = 1e-12
244
+ if self.raw.size > 1:
245
+ tol = max(tol, abs(float(np.median(np.diff(self.raw)))) * 1e-9)
246
+
247
+ left = 0 if start is None else int(np.searchsorted(values, start - tol, side="left"))
248
+ right = len(values) if stop is None else int(np.searchsorted(values, stop + tol, side="right"))
249
+
250
+ if left >= right:
251
+ raise PlotDataError("TimeAxis window contains no samples.")
252
+
253
+ return slice(left, right)
254
+
255
+ @property
256
+ def selection(self) -> slice:
257
+ """Slice selecting the active samples from the full time axis."""
258
+ return self._selection_for(self._window_start, self._window_stop)
259
+
260
+ @property
261
+ def values(self) -> np.ndarray:
262
+ """Return shifted time values inside the active window."""
263
+ return self.full_values[self.selection]
264
+
249
265
  @property
250
266
  def value(self) -> np.ndarray:
251
- """Alias for :attr:`values` for consistency with ``Trace.value``."""
267
+ """Alias for :attr:`values`."""
252
268
  return self.values
253
269
 
254
270
  @property
255
271
  def time(self) -> np.ndarray:
256
- """Alias for :attr:`values` for code that expects a time attribute."""
272
+ """Alias for :attr:`values`."""
257
273
  return self.values
258
274
 
275
+ @property
276
+ def has_window(self) -> bool:
277
+ """Whether an active time window is limiting the axis."""
278
+ return self._window_start is not None or self._window_stop is not None
279
+
280
+ @property
281
+ def window_range(self) -> tuple[float | None, float | None]:
282
+ """Current ``(start, stop)`` window in shifted time coordinates."""
283
+ return self._window_start, self._window_stop
284
+
259
285
  @property
260
286
  def count(self) -> int:
261
- """Number of raw time samples in the axis."""
287
+ """Number of samples in the active time window."""
288
+ return int(self.values.size)
289
+
290
+ @property
291
+ def raw_count(self) -> int:
292
+ """Number of samples in the original time axis."""
262
293
  return int(self.raw.size)
263
294
 
264
295
  @property
265
296
  def tmin(self) -> float:
266
- """First shifted time value."""
297
+ """First shifted time value in the active window."""
267
298
  return float(self.values[0])
268
299
 
269
300
  @property
270
301
  def tmax(self) -> float:
271
- """Last shifted time value."""
302
+ """Last shifted time value in the active window."""
272
303
  return float(self.values[-1])
273
304
 
274
305
  @property
@@ -283,53 +314,93 @@ class TimeAxis:
283
314
 
284
315
  @property
285
316
  def duration(self) -> float:
286
- """Unshifted time span covered by the axis."""
317
+ """Time span covered by the active window."""
318
+ if self.count < 2:
319
+ return 0.0
320
+ return float(self.values[-1] - self.values[0])
321
+
322
+ @property
323
+ def raw_duration(self) -> float:
324
+ """Time span covered by the original source axis."""
287
325
  if self.raw.size < 2:
288
326
  return 0.0
289
327
  return float(self.raw[-1] - self.raw[0])
290
328
 
291
329
  @property
292
330
  def dt_array(self) -> np.ndarray:
293
- """Array of adjacent raw-sample spacings."""
331
+ """Adjacent spacings inside the active window."""
332
+ return np.diff(self.values)
333
+
334
+ @property
335
+ def raw_dt_array(self) -> np.ndarray:
336
+ """Adjacent spacings in the original source axis."""
294
337
  return np.diff(self.raw)
295
338
 
296
339
  @property
297
340
  def dt(self) -> float:
298
- """Nominal timestep, computed as the median adjacent spacing."""
299
- if self.raw.size < 2:
341
+ """Nominal timestep in the active window."""
342
+ if self.count < 2:
300
343
  return float("nan")
301
344
  return float(np.median(self.dt_array))
302
345
 
303
346
  @property
304
347
  def is_uniform(self) -> bool:
305
- """Whether the raw samples are approximately uniformly spaced."""
306
- if self.raw.size < 3:
348
+ """Whether active samples are approximately uniformly spaced."""
349
+ if self.count < 3:
307
350
  return True
308
351
  dt = self.dt_array
309
352
  return bool(np.allclose(dt, np.median(dt), rtol=1e-6, atol=1e-12))
310
353
 
311
354
  def zero_at(self, time: float) -> "TimeAxis":
312
355
  """Shift the time basis so ``time`` in raw data appears as ``t = 0``."""
313
- self.zero = float(time)
356
+ zero = float(time)
357
+ if zero != self.zero:
358
+ old_zero = self.zero
359
+ self.zero = zero
360
+ try:
361
+ self._selection_for(self._window_start, self._window_stop)
362
+ except (PlotDataError, ValueError):
363
+ self.zero = old_zero
364
+ raise
314
365
  return self
315
366
 
316
367
  def align(self, data_time: float, model_time: float = 0.0) -> "TimeAxis":
317
- """Shift the axis so one raw data time aligns with one model time.
368
+ """Shift the axis so one raw data time aligns with one model time."""
369
+ return self.zero_at(float(data_time) - float(model_time))
370
+
371
+ def window(self, start: float | None = None, stop: float | None = None) -> "TimeAxis":
372
+ """Limit this shared axis, and all attached traces, to a time interval."""
373
+ start = None if start is None else float(start)
374
+ stop = None if stop is None else float(stop)
375
+ self._selection_for(start, stop)
376
+
377
+ if (start, stop) != self.window_range:
378
+ self._window_start = start
379
+ self._window_stop = stop
380
+ return self
318
381
 
319
- For example, ``axis.align(data_time=95.0, model_time=0.0)`` makes raw
320
- test time 95 seconds appear at simulation time zero.
321
- """
322
- self.zero = float(data_time) - float(model_time)
382
+ def clear_window(self) -> "TimeAxis":
383
+ """Restore the full time range."""
384
+ if self.has_window:
385
+ self._window_start = None
386
+ self._window_stop = None
323
387
  return self
324
388
 
389
+ def windowed(self, start: float | None = None, stop: float | None = None) -> "TimeAxis":
390
+ """Return an independent copy with a time window applied."""
391
+ return self.copy().window(start=start, stop=stop)
392
+
325
393
  def copy(self, name: str | None = None) -> "TimeAxis":
326
- """Return an independent copy preserving the current zero offset."""
327
- return TimeAxis(
394
+ """Return an independent copy preserving alignment and window state."""
395
+ copied = TimeAxis(
328
396
  raw=self.raw.copy(),
329
397
  name=self.name if name is None else name,
330
398
  zero=self.zero,
331
399
  attrs=self.attrs,
332
400
  )
401
+ copied._window_start = self._window_start
402
+ copied._window_stop = self._window_stop
403
+ return copied
333
404
 
334
405
  def __len__(self) -> int:
335
406
  return self.count
@@ -343,6 +414,20 @@ class TimeAxis:
343
414
  def __getitem__(self, item):
344
415
  return self.values[item]
345
416
 
417
+ def __str__(self) -> str:
418
+ """Return a compact summary of the active time range."""
419
+ active_label = "sample" if self.count == 1 else "samples"
420
+ window = ""
421
+ if self.has_window:
422
+ start, stop = self.window_range
423
+ start_text = "start" if start is None else f"{start:g}"
424
+ stop_text = "end" if stop is None else f"{stop:g}"
425
+ window = f", window={start_text} to {stop_text}"
426
+ return (
427
+ f"TimeAxis({self.name!r}, {self.count}/{self.raw_count} {active_label}, "
428
+ f"range={self.tmin:g} to {self.tmax:g}, zero={self.zero:g}{window})"
429
+ )
430
+
346
431
  class Trace:
347
432
  """Prepared one-dimensional engineering data series.
348
433
 
@@ -357,8 +442,8 @@ class Trace:
357
442
  ----------
358
443
  x:
359
444
  One-dimensional x values or a :class:`TimeAxis`. When a ``TimeAxis`` is
360
- supplied, the trace keeps a reference to it so later calls to
361
- ``time_axis.zero_at(...)`` shift all traces sharing that axis.
445
+ supplied, the trace keeps a reference to it so alignment and windowing
446
+ changes apply to every trace sharing that axis.
362
447
  y:
363
448
  One-dimensional y values. Non-finite y-values are preserved so missing
364
449
  test-data samples appear as plot gaps instead of being silently removed.
@@ -387,35 +472,68 @@ class Trace:
387
472
  else:
388
473
  self._x = None
389
474
 
390
- self.y = _as_1d_numeric_array(y, "Trace y")
391
-
392
- if self.x.shape != self.y.shape:
393
- raise PlotDataError(
394
- f"Trace x and y must have the same length. "
395
- f"Received {len(self.x)} and {len(self.y)}."
396
- )
475
+ y_array = _as_1d_numeric_array(y, "Trace y")
397
476
 
398
477
  if self.time_axis is None:
399
- mask = np.isfinite(self._x)
478
+ if self._x.shape != y_array.shape:
479
+ raise PlotDataError(
480
+ f"Trace x and y must have the same length. "
481
+ f"Received {len(self._x)} and {len(y_array)}."
482
+ )
483
+ self._raw_y = y_array
400
484
 
485
+ mask = np.isfinite(self._x)
401
486
  if not np.any(mask):
402
487
  raise PlotDataError("Trace has no finite x values.")
403
-
404
488
  if not np.all(mask):
405
489
  self._x = self._x[mask]
406
- self.y = self.y[mask]
490
+ self._raw_y = self._raw_y[mask]
491
+ else:
492
+ if len(y_array) == self.time_axis.raw_count:
493
+ self._raw_y = y_array
494
+ elif len(y_array) == self.time_axis.count:
495
+ self._raw_y = np.full(self.time_axis.raw_count, np.nan, dtype=float)
496
+ self._raw_y[self.time_axis.selection] = y_array
497
+ else:
498
+ raise PlotDataError(
499
+ f"Trace y must match the TimeAxis raw length ({self.time_axis.raw_count}) "
500
+ f"or active length ({self.time_axis.count}). Received {len(y_array)}."
501
+ )
407
502
 
408
503
  self.name = str(name)
409
504
  self.role = _normalize_trace_role(role)
410
505
  self.attrs = {} if attrs is None else dict(attrs)
411
506
 
507
+ def __str__(self) -> str:
508
+ """Return a compact summary without printing the underlying arrays."""
509
+ finite_count = int(np.count_nonzero(self.finite))
510
+ count = len(self.x)
511
+ sample_label = "sample" if count == 1 else "samples"
512
+ return (
513
+ f"Trace({self.name!r}, role={self.role!r}, {count} {sample_label}, "
514
+ f"x={self.tmin:g} to {self.tmax:g}, "
515
+ f"finite={finite_count}/{count})"
516
+ )
517
+
412
518
  @property
413
519
  def x(self) -> np.ndarray:
414
- """Current x-values for the trace, including any shared TimeAxis shift."""
520
+ """Current x-values, including shared alignment and windowing."""
415
521
  if self.time_axis is not None:
416
522
  return self.time_axis.values
417
523
  return self._x
418
524
 
525
+ @property
526
+ def y(self) -> np.ndarray:
527
+ """Current y-values, following any shared ``TimeAxis`` window."""
528
+ if self.time_axis is not None:
529
+ return self._raw_y[self.time_axis.selection]
530
+ return self._raw_y
531
+
532
+ @property
533
+ def raw_y(self) -> np.ndarray:
534
+ """Original y-array before shared ``TimeAxis`` windowing."""
535
+ return self._raw_y
536
+
419
537
  @property
420
538
  def time(self) -> np.ndarray:
421
539
  """Alias for :attr:`x` when the trace represents time-series data."""
@@ -466,9 +584,15 @@ class Trace:
466
584
  FullPlot operations should not invent new display names. If the user
467
585
  wants a new legend name, the user can pass name=... or call renamed(...).
468
586
  """
587
+ x_values = self._x_for_new_trace() if x is None else x
588
+ if y is None:
589
+ y_values = self.raw_y.copy() if isinstance(x_values, TimeAxis) else self.y.copy()
590
+ else:
591
+ y_values = np.asarray(y, dtype=float)
592
+
469
593
  return Trace(
470
- x=self._x_for_new_trace() if x is None else x,
471
- y=self.y.copy() if y is None else np.asarray(y, dtype=float),
594
+ x=x_values,
595
+ y=y_values,
472
596
  name=self.name if name is None else name,
473
597
  role=self.role if role is None else role,
474
598
  attrs=dict(self.attrs) if attrs is None else dict(attrs),
@@ -500,7 +624,7 @@ class Trace:
500
624
  """
501
625
  if isinstance(x, TimeAxis):
502
626
  x_values = x
503
- y_array = np.full(len(x), float(y), dtype=float)
627
+ y_array = np.full(x.raw_count, float(y), dtype=float)
504
628
  else:
505
629
  x_values = _as_1d_numeric_array(x, "Trace x", omit_nonfinite=True)
506
630
  y_array = np.full_like(x_values, float(y), dtype=float)
@@ -537,7 +661,7 @@ class Trace:
537
661
  x_array = xp
538
662
  elif isinstance(x, TimeAxis):
539
663
  x_values = x
540
- x_array = x.values
664
+ x_array = x.full_values
541
665
  else:
542
666
  x_values = _as_1d_numeric_array(x, "Trace x", omit_nonfinite=True)
543
667
  x_array = x_values
@@ -565,7 +689,7 @@ class Trace:
565
689
  """
566
690
  if isinstance(x, TimeAxis):
567
691
  x_values = x
568
- x_array = x.values
692
+ x_array = x.full_values
569
693
  else:
570
694
  x_values = _as_1d_numeric_array(x, "Trace x", omit_nonfinite=True)
571
695
  x_array = x_values
@@ -709,7 +833,7 @@ class Trace:
709
833
 
710
834
  return Trace(
711
835
  x=x_values,
712
- y=self.y.copy(),
836
+ y=self.raw_y.copy() if isinstance(x_values, TimeAxis) else self.y.copy(),
713
837
  name=self.name if name is None else name,
714
838
  role=self.role if role is None else role,
715
839
  attrs=merged_attrs,
@@ -871,14 +995,14 @@ class Trace:
871
995
  """
872
996
  if isinstance(x, TimeAxis):
873
997
  x_values = x
874
- x_array = x.values
998
+ x_array = x.full_values
875
999
  else:
876
1000
  x_values = _as_1d_numeric_array(x, "resample x", omit_nonfinite=True)
877
1001
  x_array = x_values
878
1002
 
879
1003
  return Trace(
880
1004
  x=x_values,
881
- y=np.interp(x_array, self.x, self.y),
1005
+ y=self.value_at(x_array, method="linear", bounds="nan"),
882
1006
  name=self.name if name is None else name,
883
1007
  role=self.role,
884
1008
  attrs=self.attrs,
@@ -1215,6 +1339,10 @@ class H5File:
1215
1339
  self.filename = str(self.filename)
1216
1340
  self.root = _clean_root(self.root)
1217
1341
 
1342
+ def __str__(self) -> str:
1343
+ """Return the selected file and HDF5 root."""
1344
+ return f"H5File({self.filename!r}, root={self.root!r})"
1345
+
1218
1346
  def at(self, group: str) -> "H5File":
1219
1347
  """
1220
1348
  Return a new H5File scoped to a specific HDF5 group.
@@ -1395,7 +1523,7 @@ class H5File:
1395
1523
  """Read a one-dimensional HDF5 dataset as a shared ``TimeAxis``.
1396
1524
 
1397
1525
  The returned axis can be passed to ``trace(..., x=time_axis)`` and later
1398
- shifted with ``zero_at`` or ``align``.
1526
+ shifted, aligned, or windowed once for all attached traces.
1399
1527
  """
1400
1528
 
1401
1529
  with h5py.File(self.filename, "r") as h5:
@@ -1455,7 +1583,7 @@ class H5File:
1455
1583
  if x is not None:
1456
1584
  if isinstance(x, TimeAxis):
1457
1585
  x_values = x
1458
- x_array = x.values
1586
+ x_array = x.full_values
1459
1587
  elif isinstance(x, Trace):
1460
1588
  x_values = x.y
1461
1589
  x_array = x.y
@@ -1541,6 +1669,9 @@ class H5File:
1541
1669
  xscale: str = "linear",
1542
1670
  yscale: str = "linear",
1543
1671
  y2scale: str = "linear",
1672
+ xlim=None,
1673
+ ylim=None,
1674
+ y2lim=None,
1544
1675
  legend: bool = True,
1545
1676
  legend_location: str = "best",
1546
1677
  grid: bool = True,
@@ -1554,11 +1685,9 @@ class H5File:
1554
1685
  """Plot HDF5 datasets and/or ``Trace`` objects.
1555
1686
 
1556
1687
  ``y`` selects one or more left-axis traces, while ``y2`` selects optional
1557
- right-axis traces. Dataset selectors can be absolute paths, paths
1558
- relative to the current root, or unique short names. Trace roles control
1559
- default line style for data, limits, references, and command traces. The
1560
- function returns the Matplotlib figure and axes so callers can add custom
1561
- annotations before showing or saving.
1688
+ right-axis traces. ``xlim``, ``ylim``, and ``y2lim`` change only the
1689
+ displayed figure range; they do not modify trace data. The function
1690
+ returns the Matplotlib figure and axes for further customization.
1562
1691
  """
1563
1692
 
1564
1693
  theme = check_theme(theme)
@@ -1569,12 +1698,14 @@ class H5File:
1569
1698
  with h5py.File(self.filename, "r") if self.filename else nullcontext(None) as h5:
1570
1699
  x_array = None
1571
1700
  x_name = None
1701
+ x_time_axis = None
1572
1702
 
1573
1703
  if x is not None:
1574
1704
  if isinstance(x, Trace):
1575
1705
  x_array = x.y
1576
1706
  x_name = x.name
1577
1707
  elif isinstance(x, TimeAxis):
1708
+ x_time_axis = x
1578
1709
  x_array = x.values
1579
1710
  x_name = x.name
1580
1711
  else:
@@ -1619,9 +1750,17 @@ class H5File:
1619
1750
 
1620
1751
  if not all_series:
1621
1752
  raise PlotDataError("No y data was selected.")
1753
+ if y2lim is not None and not right_series:
1754
+ raise PlotDataError("y2lim requires y2 data.")
1622
1755
 
1623
1756
  dataset_series = [series for series in all_series if series.x is None]
1624
1757
 
1758
+ if x_time_axis is not None:
1759
+ for series in dataset_series:
1760
+ if series.length == x_time_axis.raw_count:
1761
+ series.data = series.data[x_time_axis.selection]
1762
+ series.length = len(series.data)
1763
+
1625
1764
  if x_array is None and dataset_series:
1626
1765
  x_length = dataset_series[0].length
1627
1766
  x_array = np.arange(x_length)
@@ -1711,6 +1850,13 @@ class H5File:
1711
1850
  y2scale=y2scale,
1712
1851
  )
1713
1852
 
1853
+ if xlim is not None:
1854
+ ax.set_xlim(xlim)
1855
+ if ylim is not None:
1856
+ ax.set_ylim(ylim)
1857
+ if y2lim is not None:
1858
+ ax2.set_ylim(y2lim)
1859
+
1714
1860
  if grid:
1715
1861
  ax.grid(True, **grid_kwargs(theme))
1716
1862
  else: