visualdynamics 0.1.0a4__tar.gz → 0.1.0a5__tar.gz

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.
Files changed (135) hide show
  1. {visualdynamics-0.1.0a4/src/visualdynamics.egg-info → visualdynamics-0.1.0a5}/PKG-INFO +2 -2
  2. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/README.md +1 -1
  3. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/pyproject.toml +4 -0
  4. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/__init__.py +2 -2
  5. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/__main__.py +1 -1
  6. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/author.py +25 -9
  7. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/averaging.py +26 -10
  8. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/compliance.py +32 -10
  9. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/data.py +230 -27
  10. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/report.py +168 -42
  11. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/averaging_panel.py +12 -5
  12. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/icons.py +7 -1
  13. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/main_window.py +148 -24
  14. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/report_editor.py +86 -4
  15. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/shock_panel.py +6 -2
  16. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/stage_drag.py +17 -11
  17. visualdynamics-0.1.0a5/src/visualdynamics/gui/stream_window.py +269 -0
  18. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/truncate_panel.py +25 -0
  19. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/__init__.py +7 -0
  20. visualdynamics-0.1.0a5/src/visualdynamics/io/matlab.py +386 -0
  21. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/native.py +76 -52
  22. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/rattlesnake.py +527 -86
  23. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/__init__.py +46 -12
  24. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/averaging.py +11 -5
  25. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/bands.py +16 -6
  26. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/shocks.py +12 -6
  27. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/project.py +38 -12
  28. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/report/__init__.py +63 -21
  29. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/report/page.py +19 -5
  30. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/banded.py +37 -5
  31. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/marks.py +19 -11
  32. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/waterfall.py +18 -4
  33. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5/src/visualdynamics.egg-info}/PKG-INFO +2 -2
  34. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics.egg-info/SOURCES.txt +2 -0
  35. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics.egg-info/entry_points.txt +1 -0
  36. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/LICENSE +0 -0
  37. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/setup.cfg +0 -0
  38. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/compatibility.py +0 -0
  39. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/__init__.py +0 -0
  40. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/channel_table.py +0 -0
  41. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/correlate.py +0 -0
  42. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/detect.py +0 -0
  43. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/entities.py +0 -0
  44. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/fem.py +0 -0
  45. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/filters.py +0 -0
  46. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/geometry.py +0 -0
  47. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/kurtosis.py +0 -0
  48. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/matches.py +0 -0
  49. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/merge.py +0 -0
  50. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/modal_fit.py +0 -0
  51. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/octave.py +0 -0
  52. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/photos.py +0 -0
  53. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/replication.py +0 -0
  54. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/rigid.py +0 -0
  55. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/shapes.py +0 -0
  56. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/shocks.py +0 -0
  57. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/sine.py +0 -0
  58. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/srs.py +0 -0
  59. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/tables.py +0 -0
  60. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/transform.py +0 -0
  61. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/truncate.py +0 -0
  62. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/unit_choices.py +0 -0
  63. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/validate.py +0 -0
  64. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/core/wavelet.py +0 -0
  65. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/decimate.py +0 -0
  66. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/deform.py +0 -0
  67. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/demo/__init__.py +0 -0
  68. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/demo/drone.py +0 -0
  69. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/demo/plate.py +0 -0
  70. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/__init__.py +0 -0
  71. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/app_icon.py +0 -0
  72. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/author_panel.py +0 -0
  73. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/console.py +0 -0
  74. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/disclaimer.py +0 -0
  75. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/drawer_tab.py +0 -0
  76. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/editors.py +0 -0
  77. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/filter_panel.py +0 -0
  78. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/glyphs.py +0 -0
  79. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/icons/drawer-tab-shape.svg +0 -0
  80. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/icons/drawer-tab.svg +0 -0
  81. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/object_tables.py +0 -0
  82. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/octave_panel.py +0 -0
  83. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/panes.py +0 -0
  84. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/preferences.py +0 -0
  85. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/project_tree.py +0 -0
  86. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/record_grid.py +0 -0
  87. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/rigid_panel.py +0 -0
  88. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/settings_panel.py +0 -0
  89. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/tables.py +0 -0
  90. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/toolbars.py +0 -0
  91. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/wavelet_panel.py +0 -0
  92. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/gui/windows.py +0 -0
  93. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/adf.py +0 -0
  94. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/excel.py +0 -0
  95. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/exodus.py +0 -0
  96. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/exporters.py +0 -0
  97. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/femap.py +0 -0
  98. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/nastran.py +0 -0
  99. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/photo_files.py +0 -0
  100. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/punch.py +0 -0
  101. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/rattlesnake_specification.py +0 -0
  102. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/report_template.py +0 -0
  103. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/sdynpy_data.py +0 -0
  104. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/sdynpy_npz.py +0 -0
  105. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/sdynpy_shapes.py +0 -0
  106. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/sep005.py +0 -0
  107. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/sniffing.py +0 -0
  108. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/step.py +0 -0
  109. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/stl.py +0 -0
  110. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/threemf.py +0 -0
  111. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/io/unv.py +0 -0
  112. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/names.py +0 -0
  113. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/bars.py +0 -0
  114. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/filtering.py +0 -0
  115. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/scalogram.py +0 -0
  116. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/plot/truncation.py +0 -0
  117. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/py.typed +0 -0
  118. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/report/markdown.py +0 -0
  119. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/rotate.py +0 -0
  120. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/theme.py +0 -0
  121. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/units.py +0 -0
  122. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/update.py +0 -0
  123. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/__init__.py +0 -0
  124. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/_quiet.py +0 -0
  125. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/animate.py +0 -0
  126. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/geometry.py +0 -0
  127. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/mac_bars.py +0 -0
  128. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/paired.py +0 -0
  129. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/pick.py +0 -0
  130. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/rigid.py +0 -0
  131. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/scalogram.py +0 -0
  132. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics/viz/sinespec.py +0 -0
  133. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics.egg-info/dependency_links.txt +0 -0
  134. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics.egg-info/requires.txt +0 -0
  135. {visualdynamics-0.1.0a4 → visualdynamics-0.1.0a5}/src/visualdynamics.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visualdynamics
3
- Version: 0.1.0a4
3
+ Version: 0.1.0a5
4
4
  Summary: Structural Dynamics Visualization: a units-aware structural dynamics analysis toolset
5
5
  Author-email: Brandon Zwink <bzwink@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -155,7 +155,7 @@ newer). To work on it, install from a clone:
155
155
 
156
156
  ```bash
157
157
  pip install -e .
158
- visualdynamics-gui # the desktop app
158
+ visualdynamics # the desktop app
159
159
  ```
160
160
 
161
161
  ```python
@@ -101,7 +101,7 @@ newer). To work on it, install from a clone:
101
101
 
102
102
  ```bash
103
103
  pip install -e .
104
- visualdynamics-gui # the desktop app
104
+ visualdynamics # the desktop app
105
105
  ```
106
106
 
107
107
  ```python
@@ -87,6 +87,10 @@ Source = "https://github.com/visualdynamics/visualdynamics"
87
87
  Issues = "https://github.com/visualdynamics/visualdynamics/issues"
88
88
 
89
89
  [project.scripts]
90
+ # one window is the whole program, so the package's own name launches
91
+ # it (Brandon, 2026-09-18); the longer spelling stays for anything
92
+ # that learned it from the first alphas
93
+ visualdynamics = "visualdynamics.gui:main"
90
94
  visualdynamics-gui = "visualdynamics.gui:main"
91
95
 
92
96
  [project.optional-dependencies]
@@ -76,7 +76,7 @@ from .units import (
76
76
  #: alpha, said in the version itself so the dialog, the About text, the
77
77
  #: installers and latest.json all say the same thing, and the first
78
78
  #: non-alpha release is offered as an upgrade (Brandon, 2026-09-11)
79
- __version__ = '0.1.0a4'
79
+ __version__ = '0.1.0a5'
80
80
 
81
81
 
82
82
  def launch_gui(*paths: str, in_process: bool | None = None) -> Any:
@@ -103,7 +103,7 @@ def launch_gui(*paths: str, in_process: bool | None = None) -> Any:
103
103
  if in_process:
104
104
  from .gui import main
105
105
 
106
- # `main` hands back Qt's exit status, which `visualdynamics-gui` wants as
106
+ # `main` hands back Qt's exit status, which the `visualdynamics` command wants as
107
107
  # its own and nothing else does — it is always 0, since nothing
108
108
  # here ever calls exit() with anything else. Swallowed, so
109
109
  # closing the window from a prompt does not answer with a bare
@@ -1,4 +1,4 @@
1
- """`python -m visualdynamics` — the same entry point as the `visualdynamics-gui`
1
+ """`python -m visualdynamics` — the same entry point as the `visualdynamics`
2
2
  script, so the app starts without one being installed.
3
3
  """
4
4
 
@@ -67,6 +67,13 @@ STARTER_LEVEL = 1.0
67
67
  CONSTANT_TOLERANCE = 1e-6
68
68
 
69
69
 
70
+ #: the grid a dragged or stepped limit lands on, in decibels. A
71
+ #: whole decibel (Brandon, 2026-09-06: "never 1.5") was too coarse
72
+ #: to place a limit where it was wanted (Brandon, 2026-09-18); the
73
+ #: plot's handles snap to the same grid (`plot.bands.STEP`).
74
+ LIMIT_STEP_DB = 0.25
75
+
76
+
70
77
  @dataclass
71
78
  class SpecificationDraft:
72
79
  """A specification being written, at any channels.
@@ -128,7 +135,15 @@ class SpecificationDraft:
128
135
  def __post_init__(self) -> None:
129
136
  from .validate import dofs as _dofs
130
137
 
131
- self.channels = _dofs(list(self.channels), 'channel')
138
+ # what the object accepts, the sheet accepts: a virtual point's
139
+ # rows come in numbered '1', '2', '3' with no direction, since
140
+ # the controller's file names them nowhere, and the sheet
141
+ # refused to open on them (Brandon, 2026-09-18). Only an empty
142
+ # name is refused — a channel has to be called something
143
+ self.channels = _dofs(list(self.channels), 'channel',
144
+ allow_unknown=True)
145
+ if any(not name for name in self.channels):
146
+ raise ValueError('every channel needs a name')
132
147
  self.dims = [str(d) for d in self.dims]
133
148
  self.frequencies = [float(f) for f in self.frequencies]
134
149
  self.levels = [[float(v) for v in row] for row in self.levels]
@@ -773,12 +788,13 @@ class SpecificationDraft:
773
788
 
774
789
  def shifted_band(self, kind: str, side: str, segments: Any,
775
790
  decibels: float, channels: Any = None,
776
- step: float = 1.0) -> SpecificationDraft:
791
+ step: float = LIMIT_STEP_DB) -> SpecificationDraft:
777
792
  """The same draft with one edge of a band moved — the drag on
778
793
  the plot, landed. A symmetric channel moves the other edge to
779
794
  match; a uniform channel moves every segment. The edge lands
780
- on a multiple of `step` (Brandon, 2026-09-06: whole decibels,
781
- never 1.5), and never nearer the target than one step.
795
+ on a multiple of `step` (`LIMIT_STEP_DB`: a quarter decibel,
796
+ Brandon 2026-09-18, from the whole decibel of 2026-09-06), and
797
+ never nearer the target than one step.
782
798
 
783
799
  Parameters
784
800
  ----------
@@ -793,7 +809,7 @@ class SpecificationDraft:
793
809
  channels : list of str, optional
794
810
  Which channels; all of them when omitted — the channels
795
811
  selected are the channels edited.
796
- step : float, default 1.0
812
+ step : float, default LIMIT_STEP_DB
797
813
  The decibel grid the edge lands on.
798
814
 
799
815
  Returns
@@ -806,7 +822,7 @@ class SpecificationDraft:
806
822
 
807
823
  def with_band_edge(self, kind: str, side: str, segments: Any,
808
824
  decibels: float, channels: Any = None,
809
- step: float = 1.0) -> SpecificationDraft:
825
+ step: float = LIMIT_STEP_DB) -> SpecificationDraft:
810
826
  """The same draft with one edge of a band set to `decibels` on
811
827
  every channel — the drag on the plot, landed (Brandon,
812
828
  2026-09-06: a drag from 3 to 4 dB puts every selected channel
@@ -828,7 +844,7 @@ class SpecificationDraft:
828
844
  The edge's level, positive above the target.
829
845
  channels : list of str, optional
830
846
  Which channels; all of them when omitted.
831
- step : float, default 1.0
847
+ step : float, default LIMIT_STEP_DB
832
848
  The decibel grid the edge lands on.
833
849
 
834
850
  Returns
@@ -1285,7 +1301,7 @@ class SpecificationDraft:
1285
1301
  return below if side == 'lower' else above
1286
1302
 
1287
1303
  def carried(values):
1288
- return _at_lines(old_f, values, lines)
1304
+ return read_at_lines(old_f, values, lines)
1289
1305
 
1290
1306
  def autos_at(name, i):
1291
1307
  # the sheet inside its range, the specification's own
@@ -1493,7 +1509,7 @@ def uniform_spacing(frequencies: Any) -> float | None:
1493
1509
  return float(steps[0])
1494
1510
 
1495
1511
 
1496
- def _at_lines(old_f: np.ndarray, values: np.ndarray,
1512
+ def read_at_lines(old_f: np.ndarray, values: np.ndarray,
1497
1513
  lines: np.ndarray) -> np.ndarray:
1498
1514
  """A specification's record read at new lines: its own value where
1499
1515
  a line is one of its own, and between them the power law a
@@ -80,7 +80,8 @@ def normalize_window(name: str | None) -> str:
80
80
 
81
81
 
82
82
  def from_span(averaging: Averaging, low: float, high: float,
83
- sample_rate: float, samples: int) -> Averaging:
83
+ sample_rate: float, samples: int,
84
+ origin: float = 0.0) -> Averaging:
84
85
  """The averaging a dragged span means, in whole frames.
85
86
 
86
87
  The frame length and the window are the table's; a drag says only
@@ -91,8 +92,11 @@ def from_span(averaging: Averaging, low: float, high: float,
91
92
  """
92
93
  rate = sample_rate
93
94
  n = averaging.frame_length
95
+ # the drag is on the record's clock; the start counts from the
96
+ # record's beginning (`Averaging.stop` says why they differ)
97
+ low, high = float(low) - origin, float(high) - origin
94
98
  last_start = max(samples - n, 0) / rate
95
- start = min(max(float(low), 0.0), last_start)
99
+ start = min(max(low, 0.0), last_start)
96
100
  moved = replace(averaging, start=start)
97
101
  width = round((float(high) - start) * rate)
98
102
  wanted = 1 if width < n else 1 + (width - n) // moved.hop
@@ -265,16 +269,28 @@ class Averaging:
265
269
  def start_sample(self, sample_rate: float) -> int:
266
270
  return round(self.start * sample_rate)
267
271
 
268
- def stop(self, sample_rate: float) -> float:
269
- """When the analysis ends, in seconds — derived, never stored."""
270
- return (self.start_sample(sample_rate) + self.span) / sample_rate
272
+ def stop(self, sample_rate: float, origin: float = 0.0) -> float:
273
+ """When the analysis ends, in seconds — derived, never stored.
274
+
275
+ `origin` is the record's first instant. `start` counts from
276
+ the record's beginning, a sample count in disguise, and stays
277
+ so whatever the clock says; but a record whose clock does not
278
+ begin at zero — a truncation keeps its measured instants, an
279
+ import window reads the run's — is drawn and dragged on that
280
+ clock, and the frames of one that began at 1060 s were being
281
+ drawn at 0 s, off the plot (Brandon, 2026-09-18). Every
282
+ reading in seconds takes the origin here, once.
283
+ """
284
+ return origin + (self.start_sample(sample_rate) + self.span) / sample_rate
271
285
 
272
- def frame_bounds(self, sample_rate: float) -> list[tuple[float, float]]:
273
- """[(start, stop)] in seconds, one per frame, overlaps included —
274
- what the time history plot shades."""
286
+ def frame_bounds(self, sample_rate: float,
287
+ origin: float = 0.0) -> list[tuple[float, float]]:
288
+ """[(start, stop)] in seconds on the record's clock, one per
289
+ frame, overlaps included — what the time history plot shades.
290
+ `origin` as in `stop`."""
275
291
  first = self.start_sample(sample_rate)
276
- return [((first + i * self.hop) / sample_rate,
277
- (first + i * self.hop + self.frame_length) / sample_rate)
292
+ return [(origin + (first + i * self.hop) / sample_rate,
293
+ origin + (first + i * self.hop + self.frame_length) / sample_rate)
278
294
  for i in range(self.frames)]
279
295
 
280
296
  def fits(self, samples: int, sample_rate: float) -> bool:
@@ -152,11 +152,26 @@ def written_band(frequencies: ArrayLike,
152
152
 
153
153
  def band_of(specification: Specification,
154
154
  record: int = 0) -> tuple[float, float] | None:
155
- """(low, high) the specification actually says something over."""
156
- return written_band(specification.abscissa, specification.ordinate[record])
155
+ """(low, high) the specification actually says something over.
156
+
157
+ A specification on bands says something over each band's whole
158
+ width, so its range is its outer bin edges — not its first and last
159
+ centers, which sit inside them and would shrink every comparison
160
+ against it by half a band at each end.
161
+ """
162
+ frequencies = np.asarray(specification.abscissa, dtype=float)
163
+ values = np.asarray(np.real(specification.ordinate[record]), dtype=float)
164
+ band = written_band(frequencies, values)
165
+ if band is None or getattr(specification, 'bandwidth', None) is None:
166
+ return band
167
+ left, right = specification.bin_bounds()
168
+ usable = (np.isfinite(frequencies) & (frequencies > 0.0)
169
+ & np.isfinite(values) & (values > 0.0))
170
+ return float(left[usable].min()), float(right[usable].max())
157
171
 
158
172
 
159
- def covered(lines: ArrayLike, low: float, high: float
173
+ def covered(lines: ArrayLike, low: float, high: float,
174
+ widths: ArrayLike | None = None
160
175
  ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
161
176
  """Each measured bin cut to the part of it a band covers.
162
177
 
@@ -171,14 +186,15 @@ def covered(lines: ArrayLike, low: float, high: float
171
186
  if lines.size < 2:
172
187
  empty = np.zeros(lines.shape)
173
188
  return empty, empty, empty, np.zeros(lines.shape, dtype=bool)
174
- widths = np.gradient(lines)
189
+ # a banded measurement's bins are its own, geometric ones
190
+ widths = np.gradient(lines) if widths is None else np.asarray(widths)
175
191
  start = np.maximum(lines - widths / 2.0, low)
176
192
  stop = np.minimum(lines + widths / 2.0, high)
177
193
  width = np.clip(stop - start, 0.0, None)
178
194
  return start, stop, width, (width > 0.0) & (width < widths * (1.0 - 1e-9))
179
195
 
180
196
 
181
- def _written_area(frequencies, values, low, high, reading):
197
+ def _written_area(frequencies, values, low, high, reading, widths=None):
182
198
  """The area under a written curve, read the way its owner is.
183
199
 
184
200
  A limit belongs to a specification and is written the way the
@@ -192,7 +208,9 @@ def _written_area(frequencies, values, low, high, reading):
192
208
  return log_log_area(frequencies, values, low, high)
193
209
  from .octave import bin_bounds
194
210
 
195
- left, right = bin_bounds(np.asarray(frequencies, dtype=float))
211
+ # a banded specification carries its own bins — geometric, from a
212
+ # standard — and the midpoints between its centers are not them
213
+ left, right = bin_bounds(np.asarray(frequencies, dtype=float), widths)
196
214
  if low is not None:
197
215
  left, right = np.maximum(left, low), np.maximum(right, low)
198
216
  if high is not None:
@@ -207,7 +225,9 @@ def _written_area(frequencies, values, low, high, reading):
207
225
 
208
226
  def outside(lines: ArrayLike, values: ArrayLike,
209
227
  spec_frequencies: ArrayLike, limit_values: ArrayLike,
210
- over: bool = True, reading: str = 'log_log') -> np.ndarray:
228
+ over: bool = True, reading: str = 'log_log',
229
+ spec_widths: ArrayLike | None = None,
230
+ widths: ArrayLike | None = None) -> np.ndarray:
211
231
  """Which measured lines fell outside one written limit curve.
212
232
 
213
233
  In the middle of the band this is the plain comparison: the density
@@ -234,10 +254,10 @@ def outside(lines: ArrayLike, values: ArrayLike,
234
254
  band = written_band(spec_frequencies, limit_values)
235
255
  if band is None:
236
256
  return out
237
- start, stop, width, cut = covered(lines, *band)
257
+ start, stop, width, cut = covered(lines, *band, widths)
238
258
  for i in np.flatnonzero(cut & real):
239
259
  asked = _written_area(spec_frequencies, limit_values,
240
- start[i], stop[i], reading)
260
+ start[i], stop[i], reading, spec_widths)
241
261
  if not np.isfinite(asked):
242
262
  continue
243
263
  held = values[i] * width[i]
@@ -516,7 +536,9 @@ def exceedances(specification: Specification, measured: DataArray,
516
536
  else outside(lines, got, specification.abscissa,
517
537
  written[spec_record], over,
518
538
  getattr(specification, 'interpolation',
519
- 'log_log')))
539
+ 'log_log'),
540
+ getattr(specification, 'bandwidth', None),
541
+ getattr(measured, 'bandwidth', None)))
520
542
  return marks[0], marks[1]
521
543
 
522
544
 
@@ -58,6 +58,13 @@ from ..units import (
58
58
  si_factor,
59
59
  si_transform,
60
60
  )
61
+
62
+ #: how much of an object's ordinate a display conversion holds at
63
+ #: once, in bytes (`DataArray.display_blocks`): a quarter gigabyte is
64
+ #: a few hundred records of a long run, and a block's transients —
65
+ #: the gather, the scaling, the decimator's padding — are bounded by
66
+ #: it whatever the object weighs
67
+ DISPLAY_BLOCK_BYTES = 256 * 2 ** 20
61
68
  from .validate import DIRECTION_CODES
62
69
  from .validate import dofs as _dofs
63
70
 
@@ -853,17 +860,62 @@ class DataArray:
853
860
  rows = range(self.num_records) if records is None else list(records)
854
861
  out = np.empty((len(rows), self.ordinate.shape[1]),
855
862
  dtype=self.ordinate.dtype)
856
- by_dimension = {}
857
- for position, record in enumerate(rows):
858
- by_dimension.setdefault(self.ordinate_dim[record], []).append(
859
- (position, record))
860
- for dim, pairs in by_dimension.items():
861
- positions = [position for position, _ in pairs]
862
- source = self.ordinate[[record for _, record in pairs]]
863
- out[positions] = (source if dim == UNKNOWN
864
- else unit_system.from_si(source, dim))
863
+ # converted a block of records at a time into slices of `out`:
864
+ # gathering every asked-for record and scaling the gather were
865
+ # two whole copies beside the answer, and on a 1.2 GB history
866
+ # the conversion alone peaked at three times its output
867
+ # (measured 2026-09-18, on the way to a 22 GB import that took
868
+ # the application down at 67 GB)
869
+ for start, stop, block in self.display_blocks(unit_system, rows):
870
+ out[start:stop] = block
865
871
  return out
866
872
 
873
+ def display_blocks(self, unit_system: UnitSystem,
874
+ records: Iterable[int] | None = None,
875
+ block_bytes: int | None = None):
876
+ """`display_ordinate` a block of records at a time.
877
+
878
+ Yields `(start, stop, values)`: positions within `records` and
879
+ the converted rows for them, each block at most `block_bytes`
880
+ (`DISPLAY_BLOCK_BYTES` by default) — so a reading that thins
881
+ every record before it draws it, the 3-D stage above all,
882
+ never holds a converted copy of the whole object. A record
883
+ larger than the block is still one block: a row is not split.
884
+
885
+ Parameters
886
+ ----------
887
+ unit_system : UnitSystem
888
+ The units to present the values in.
889
+ records : iterable of int, optional
890
+ Which records to convert. All of them when omitted.
891
+ block_bytes : int, optional
892
+ The most a block may hold, in bytes.
893
+
894
+ Yields
895
+ ------
896
+ tuple of (int, int, numpy.ndarray)
897
+ The block's positions among `records`, and its values in
898
+ display units.
899
+ """
900
+ rows = range(self.num_records) if records is None else list(records)
901
+ limit = DISPLAY_BLOCK_BYTES if block_bytes is None else int(block_bytes)
902
+ row_bytes = max(1, self.ordinate.shape[1] * self.ordinate.dtype.itemsize)
903
+ per_block = max(1, limit // row_bytes)
904
+ for start in range(0, len(rows), per_block):
905
+ chunk = rows[start:start + per_block]
906
+ block = np.empty((len(chunk), self.ordinate.shape[1]),
907
+ dtype=self.ordinate.dtype)
908
+ by_dimension = {}
909
+ for position, record in enumerate(chunk):
910
+ by_dimension.setdefault(self.ordinate_dim[record], []).append(
911
+ (position, record))
912
+ for dim, pairs in by_dimension.items():
913
+ positions = [position for position, _ in pairs]
914
+ source = self.ordinate[[record for _, record in pairs]]
915
+ block[positions] = (source if dim == UNKNOWN
916
+ else unit_system.from_si(source, dim))
917
+ yield start, start + len(chunk), block
918
+
867
919
  def __eq__(self, other: object) -> bool:
868
920
  if type(self) is not type(other):
869
921
  return NotImplemented
@@ -2535,26 +2587,11 @@ class Psd(DataArray):
2535
2587
  Psd
2536
2588
  The same power, arranged on proportional bands.
2537
2589
  """
2538
- from .octave import PER_OCTAVE, bands, resample
2590
+ from .octave import resample
2539
2591
 
2540
2592
  frequencies = np.asarray(self.abscissa, dtype=float)
2541
- positive = frequencies[frequencies > 0.0]
2542
- if positive.size < 2:
2543
- raise ValueError(
2544
- 'octave bands need a spectrum with frequency lines above '
2545
- 'zero; this one has ' + str(positive.size))
2546
- # the range the bands have to span is the range the *bins*
2547
- # cover, not the range the centers do. A line at 0.5 Hz stands
2548
- # for a bin reaching down to 0.25, and a grid that started at
2549
- # the line would leave that half outside every band — which is
2550
- # a small loss of area and an unnecessary one.
2551
- reach, beyond = self.bin_bounds()
2552
- inside = reach[reach > 0.0]
2553
- low = (float(inside.min()) if inside.size else float(positive.min())) \
2554
- if low is None else float(low)
2555
- high = float(beyond.max()) if high is None else float(high)
2556
- per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
2557
- centers, widths, bounds = bands(low, high, per_octave)
2593
+ per_octave, centers, widths, bounds = self._octave_grid(
2594
+ per_octave, low, high)
2558
2595
  banded = resample(frequencies, self.ordinate, bounds, widths,
2559
2596
  source=self.bin_bounds())
2560
2597
  out = Psd(centers, banded,
@@ -2574,6 +2611,32 @@ class Psd(DataArray):
2574
2611
  out.scale_db = self.scale_db
2575
2612
  return out
2576
2613
 
2614
+ def _octave_grid(self, per_octave, low, high):
2615
+ """(per_octave, centers, widths, bounds): the bands this spectrum
2616
+ bands onto — one grid, shared by the ordinate and, for a
2617
+ specification, its limits."""
2618
+ from .octave import PER_OCTAVE, bands
2619
+
2620
+ frequencies = np.asarray(self.abscissa, dtype=float)
2621
+ positive = frequencies[frequencies > 0.0]
2622
+ if positive.size < 2:
2623
+ raise ValueError(
2624
+ 'octave bands need a spectrum with frequency lines above '
2625
+ 'zero; this one has ' + str(positive.size))
2626
+ # the range the bands have to span is the range the *bins*
2627
+ # cover, not the range the centers do. A line at 0.5 Hz stands
2628
+ # for a bin reaching down to 0.25, and a grid that started at
2629
+ # the line would leave that half outside every band — which is
2630
+ # a small loss of area and an unnecessary one.
2631
+ reach, beyond = self.bin_bounds()
2632
+ inside = reach[reach > 0.0]
2633
+ low = (float(inside.min()) if inside.size else float(positive.min())) \
2634
+ if low is None else float(low)
2635
+ high = float(beyond.max()) if high is None else float(high)
2636
+ per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
2637
+ centers, widths, bounds = bands(low, high, per_octave)
2638
+ return per_octave, centers, widths, bounds
2639
+
2577
2640
  def bin_widths(self) -> np.ndarray:
2578
2641
  """The width of every line's own bin.
2579
2642
 
@@ -2792,6 +2855,146 @@ class Specification(Bounded, Psd):
2792
2855
  #: law. `compute_psds` says otherwise for one it computed.
2793
2856
  interpolation = 'log_log'
2794
2857
 
2858
+ #: how finely a breakpoint curve's cross terms are read when a band
2859
+ #: integrates them: points per band on a log grid
2860
+ _CROSS_POINTS = 64
2861
+
2862
+ def to_octave(self, per_octave: int | None = None,
2863
+ low: float | None = None,
2864
+ high: float | None = None) -> Specification:
2865
+ """This specification integrated onto proportional bands, its
2866
+ warning and abort limits with it.
2867
+
2868
+ The same area rule as `Psd.to_octave`, read the way the object
2869
+ is: a specification computed on lines is a density and each
2870
+ line is a bin; one written at breakpoints is a power law
2871
+ between them, and each band takes the exact area under that law
2872
+ (`compliance.log_log_area`) over the part of the band the
2873
+ specification covers, divided by the band's width. The limits
2874
+ are curves written the same way and go through the same rule,
2875
+ so a band's warning line stands in the same relation to its
2876
+ target as the breakpoints did. Cross terms of a breakpoint
2877
+ specification are not power laws (a phase is not), so they are
2878
+ read onto a fine log grid the way the authoring sheet reads
2879
+ them — magnitude log–log, phase straight — and integrated there.
2880
+
2881
+ The result is a density per band (`interpolation` 'bin', the
2882
+ bands' widths carried), which is what a banded measurement is
2883
+ compared against band for band. Brandon, 2026-09-18: the
2884
+ specification and the measurement it judges should be
2885
+ convertible alike, so a compliance can be read on bands at both
2886
+ ends — reversing the earlier reasoning (PLAN.md, "Octave
2887
+ bands") that a written curve needed no banding because the
2888
+ comparison integrates it exactly; it does, and a banded
2889
+ specification is still the object a person asks to see and
2890
+ hand on.
2891
+
2892
+ Parameters
2893
+ ----------
2894
+ per_octave : int, optional
2895
+ Bands per octave.
2896
+ low, high : float, optional
2897
+ The band to cover.
2898
+
2899
+ Returns
2900
+ -------
2901
+ Specification
2902
+ The same power and the same limits, arranged on
2903
+ proportional bands.
2904
+ """
2905
+ from .compliance import log_log_area, written_band
2906
+ from .octave import resample
2907
+
2908
+ frequencies = np.asarray(self.abscissa, dtype=float)
2909
+ rows = np.atleast_2d(self.ordinate)
2910
+ if self.interpolation == 'log_log':
2911
+ # the grid spans what the curve is written over — its first
2912
+ # breakpoint to its last — not bins inferred between
2913
+ # breakpoints, which put the first band at the midpoint of
2914
+ # the first segment and lost everything below it
2915
+ spans = [written_band(frequencies, np.abs(row)) for row in rows]
2916
+ spans = [span for span in spans if span is not None]
2917
+ if not spans:
2918
+ raise ValueError('octave bands need a specification written '
2919
+ 'over a range of frequencies above zero')
2920
+ from .octave import PER_OCTAVE, bands
2921
+
2922
+ per_octave = PER_OCTAVE if per_octave is None else int(per_octave)
2923
+ low = min(span[0] for span in spans) if low is None else float(low)
2924
+ high = (max(span[1] for span in spans) if high is None
2925
+ else float(high))
2926
+ centers, widths, bounds = bands(low, high, per_octave)
2927
+
2928
+ def band_real(row):
2929
+ out = np.full(len(centers), np.nan)
2930
+ band = written_band(frequencies, row)
2931
+ if band is None:
2932
+ return out
2933
+ for k in range(len(centers)):
2934
+ lo = max(bounds[k], band[0])
2935
+ hi = min(bounds[k + 1], band[1])
2936
+ if hi > lo:
2937
+ out[k] = log_log_area(frequencies, row, lo, hi) \
2938
+ / widths[k]
2939
+ return out
2940
+
2941
+ def band_complex(row):
2942
+ from .author import read_at_lines
2943
+
2944
+ out = np.full(len(centers), np.nan, dtype=complex)
2945
+ band = written_band(frequencies, np.abs(row))
2946
+ if band is None:
2947
+ return out
2948
+ for k in range(len(centers)):
2949
+ lo = max(bounds[k], band[0])
2950
+ hi = min(bounds[k + 1], band[1])
2951
+ if hi > lo:
2952
+ grid = np.geomspace(lo, hi, self._CROSS_POINTS)
2953
+ values = read_at_lines(frequencies, row, grid)
2954
+ out[k] = np.trapezoid(values, grid) / widths[k]
2955
+ return out
2956
+
2957
+ banded = np.array([
2958
+ band_complex(row) if np.iscomplexobj(row) and np.any(row.imag)
2959
+ else band_real(np.real(row)) for row in rows])
2960
+ limits = {name: np.array([band_real(row) for row in values])
2961
+ for name, values in self.limits.items()}
2962
+ else:
2963
+ per_octave, centers, widths, bounds = self._octave_grid(
2964
+ per_octave, low, high)
2965
+ source = self.bin_bounds()
2966
+ banded = resample(frequencies, rows, bounds, widths, source=source)
2967
+ limits = {}
2968
+ for name, values in self.limits.items():
2969
+ values = np.atleast_2d(values)
2970
+ # a limit row that is all NaN is a cross term's, which
2971
+ # has no limit of its own; the integration would read
2972
+ # its NaN as nothing and hand back zeros
2973
+ unwritten = ~np.isfinite(values).any(axis=1)
2974
+ out = resample(frequencies, values, bounds, widths,
2975
+ source=source).astype(float)
2976
+ out[unwritten] = np.nan
2977
+ limits[name] = out
2978
+ out = Specification(
2979
+ centers, banded,
2980
+ response_dof=list(self.response_dof),
2981
+ reference_dof=(None if self.reference_dof is None
2982
+ else list(self.reference_dof)),
2983
+ ordinate_dim=list(self.ordinate_dim),
2984
+ ordinate_unit=list(self.ordinate_unit),
2985
+ reference_unit=list(self.reference_unit),
2986
+ dimension_hint=list(self.dimension_hint),
2987
+ block=None if self.block is None else list(self.block),
2988
+ bandwidth=widths,
2989
+ comment=f'1/{per_octave} octave bands',
2990
+ **limits)
2991
+ out.interpolation = 'bin'
2992
+ out.scale_db = self.scale_db
2993
+ constraints = getattr(self, 'band_constraints', None)
2994
+ if constraints:
2995
+ out.band_constraints = dict(constraints)
2996
+ return out
2997
+
2795
2998
 
2796
2999
  class ShockSpecification(Bounded, Srs):
2797
3000
  """What a shock test was controlled to: an SRS, and its band.