visualdynamics 0.1.0a2__tar.gz → 0.1.0a3__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 (133) hide show
  1. {visualdynamics-0.1.0a2/src/visualdynamics.egg-info → visualdynamics-0.1.0a3}/PKG-INFO +2 -1
  2. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/pyproject.toml +7 -0
  3. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/__init__.py +1 -1
  4. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/wavelet.py +134 -22
  5. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/main_window.py +8 -6
  6. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/__init__.py +7 -4
  7. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/report/__init__.py +7 -13
  8. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/update.py +27 -1
  9. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3/src/visualdynamics.egg-info}/PKG-INFO +2 -1
  10. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics.egg-info/requires.txt +1 -0
  11. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/LICENSE +0 -0
  12. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/README.md +0 -0
  13. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/setup.cfg +0 -0
  14. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/__main__.py +0 -0
  15. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/compatibility.py +0 -0
  16. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/__init__.py +0 -0
  17. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/author.py +0 -0
  18. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/averaging.py +0 -0
  19. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/channel_table.py +0 -0
  20. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/compliance.py +0 -0
  21. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/correlate.py +0 -0
  22. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/data.py +0 -0
  23. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/detect.py +0 -0
  24. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/entities.py +0 -0
  25. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/fem.py +0 -0
  26. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/filters.py +0 -0
  27. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/geometry.py +0 -0
  28. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/kurtosis.py +0 -0
  29. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/matches.py +0 -0
  30. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/merge.py +0 -0
  31. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/modal_fit.py +0 -0
  32. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/octave.py +0 -0
  33. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/photos.py +0 -0
  34. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/replication.py +0 -0
  35. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/report.py +0 -0
  36. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/rigid.py +0 -0
  37. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/shapes.py +0 -0
  38. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/shocks.py +0 -0
  39. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/sine.py +0 -0
  40. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/srs.py +0 -0
  41. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/tables.py +0 -0
  42. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/transform.py +0 -0
  43. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/truncate.py +0 -0
  44. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/unit_choices.py +0 -0
  45. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/core/validate.py +0 -0
  46. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/decimate.py +0 -0
  47. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/deform.py +0 -0
  48. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/demo/__init__.py +0 -0
  49. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/demo/drone.py +0 -0
  50. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/demo/plate.py +0 -0
  51. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/__init__.py +0 -0
  52. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/app_icon.py +0 -0
  53. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/author_panel.py +0 -0
  54. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/averaging_panel.py +0 -0
  55. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/console.py +0 -0
  56. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/disclaimer.py +0 -0
  57. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/drawer_tab.py +0 -0
  58. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/editors.py +0 -0
  59. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/filter_panel.py +0 -0
  60. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/glyphs.py +0 -0
  61. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/icons/drawer-tab-shape.svg +0 -0
  62. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/icons/drawer-tab.svg +0 -0
  63. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/icons.py +0 -0
  64. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/object_tables.py +0 -0
  65. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/octave_panel.py +0 -0
  66. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/panes.py +0 -0
  67. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/preferences.py +0 -0
  68. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/project_tree.py +0 -0
  69. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/record_grid.py +0 -0
  70. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/report_editor.py +0 -0
  71. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/rigid_panel.py +0 -0
  72. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/settings_panel.py +0 -0
  73. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/shock_panel.py +0 -0
  74. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/stage_drag.py +0 -0
  75. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/tables.py +0 -0
  76. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/toolbars.py +0 -0
  77. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/truncate_panel.py +0 -0
  78. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/wavelet_panel.py +0 -0
  79. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/gui/windows.py +0 -0
  80. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/__init__.py +0 -0
  81. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/adf.py +0 -0
  82. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/excel.py +0 -0
  83. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/exodus.py +0 -0
  84. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/exporters.py +0 -0
  85. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/femap.py +0 -0
  86. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/nastran.py +0 -0
  87. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/native.py +0 -0
  88. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/photo_files.py +0 -0
  89. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/punch.py +0 -0
  90. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/rattlesnake.py +0 -0
  91. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/rattlesnake_specification.py +0 -0
  92. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/report_template.py +0 -0
  93. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/sdynpy_data.py +0 -0
  94. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/sdynpy_npz.py +0 -0
  95. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/sdynpy_shapes.py +0 -0
  96. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/sep005.py +0 -0
  97. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/sniffing.py +0 -0
  98. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/step.py +0 -0
  99. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/stl.py +0 -0
  100. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/threemf.py +0 -0
  101. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/io/unv.py +0 -0
  102. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/names.py +0 -0
  103. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/averaging.py +0 -0
  104. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/bands.py +0 -0
  105. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/bars.py +0 -0
  106. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/filtering.py +0 -0
  107. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/scalogram.py +0 -0
  108. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/shocks.py +0 -0
  109. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/plot/truncation.py +0 -0
  110. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/project.py +0 -0
  111. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/py.typed +0 -0
  112. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/report/markdown.py +0 -0
  113. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/report/page.py +0 -0
  114. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/rotate.py +0 -0
  115. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/theme.py +0 -0
  116. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/units.py +0 -0
  117. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/__init__.py +0 -0
  118. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/_quiet.py +0 -0
  119. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/animate.py +0 -0
  120. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/banded.py +0 -0
  121. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/geometry.py +0 -0
  122. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/mac_bars.py +0 -0
  123. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/marks.py +0 -0
  124. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/paired.py +0 -0
  125. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/pick.py +0 -0
  126. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/rigid.py +0 -0
  127. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/scalogram.py +0 -0
  128. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/sinespec.py +0 -0
  129. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics/viz/waterfall.py +0 -0
  130. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics.egg-info/SOURCES.txt +0 -0
  131. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics.egg-info/dependency_links.txt +0 -0
  132. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/src/visualdynamics.egg-info/entry_points.txt +0 -0
  133. {visualdynamics-0.1.0a2 → visualdynamics-0.1.0a3}/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.0a2
3
+ Version: 0.1.0a3
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
@@ -22,6 +22,7 @@ Requires-Python: >=3.12
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: numpy
25
+ Requires-Dist: certifi
25
26
  Requires-Dist: scipy
26
27
  Requires-Dist: h5py
27
28
  Requires-Dist: netCDF4
@@ -38,6 +38,13 @@ classifiers = [
38
38
  ]
39
39
  dependencies = [
40
40
  "numpy",
41
+ # MPL-2.0, which is file-level copyleft on certifi's own files and
42
+ # nothing more for a program that ships them unmodified; already in
43
+ # every package as netCDF4's dependency. Declared because the update
44
+ # check imports it: the packaged OpenSSL's compiled-in certificate
45
+ # directory exists on nobody's machine, and this is the trust store
46
+ # it falls back to (2026-09-15).
47
+ "certifi",
41
48
  # BSD-3-Clause. Runtime, not test-only: the low-pass, the
42
49
  # integration, the filter view's response curve and the sine matched
43
50
  # filter all call it. It was declared a test dependency for a long
@@ -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.0a2'
79
+ __version__ = '0.1.0a3'
80
80
 
81
81
 
82
82
  def launch_gui(*paths: str, in_process: bool | None = None) -> Any:
@@ -112,6 +112,25 @@ OMEGA0 = 6.0
112
112
  #: looks continuous without computing scales nobody can see apart.
113
113
  PER_OCTAVE = 12
114
114
 
115
+ #: bytes one band of the transform may hold in each of its working
116
+ #: arrays. The one-shot form keeps three arrays of (scales, padded
117
+ #: length) complex values alive at once — the daughters, their product
118
+ #: with the spectrum, and the inverse — and on a five-minute record at
119
+ #: 16 kHz that is 121 rows of 4.9M samples, 27 GB, which is how the
120
+ #: desktop app died (Brandon, 2026-09-15). Cut into bands of rows that
121
+ #: fit this budget, the peak is three of these plus the answer,
122
+ #: whatever the record's length; the rows are independent, so the
123
+ #: cut costs nothing but the loop. 256 MB is 3.4M samples a row on
124
+ #: complex128: a minute at 16 kHz is still one band.
125
+ BAND_BYTES = 256 * 2**20
126
+
127
+ #: columns a picture-sized reading holds time to. A screen is a few
128
+ #: thousand pixels across and a surface of this width is half a million
129
+ #: vertices at the default grid — the 3-D stage's own budget is ten
130
+ #: million — so nothing a reader could see is lost, and a five-minute
131
+ #: record arrives as 60 KB rather than 4 GB.
132
+ COLUMNS = 4096
133
+
115
134
 
116
135
  def fourier_factor(omega0: float = OMEGA0) -> float:
117
136
  """Seconds of Fourier period per second of wavelet scale.
@@ -199,6 +218,14 @@ def scalogram(values: ArrayLike, sample_rate: float,
199
218
  the record's FFT and one inverse, rather than a convolution per
200
219
  scale in time.
201
220
 
221
+ Computed a band of scales at a time (`BAND_BYTES`), because every
222
+ scale at once is three arrays of (scales, padded length) complex
223
+ values and a long record turns that into tens of gigabytes. The
224
+ answer here is still the whole thing — one complex row per
225
+ frequency, as long as the record — which for a long record is
226
+ itself gigabytes; a picture wants `scalogram_peaks`, which reduces
227
+ each band as it lands and never holds the whole.
228
+
202
229
  Normalised so that **the magnitude is the record's own amplitude**:
203
230
  a 2 g tone reads 2 wherever it sits on the frequency axis, so the
204
231
  colour bar carries the record's units and a ridge's height means
@@ -238,8 +265,69 @@ def scalogram(values: ArrayLike, sample_rate: float,
238
265
  nothing to keep and a ridge's phase is what an instantaneous
239
266
  frequency would be read from.
240
267
  """
241
- from scipy.fft import fft, ifft, next_fast_len
268
+ record, wanted, samples = _prepared(values, sample_rate, frequencies)
269
+ out = np.empty((wanted.size, samples), dtype=complex)
270
+ for rows, band in _bands(record, sample_rate, wanted, omega0):
271
+ out[rows] = band
272
+ return out
273
+
274
+
275
+ def scalogram_peaks(values: ArrayLike, sample_rate: float,
276
+ frequencies: ArrayLike, omega0: float = OMEGA0, *,
277
+ columns: int = COLUMNS) -> tuple[np.ndarray, np.ndarray]:
278
+ """The scalogram's magnitude, held to at most `columns` of time.
279
+
280
+ What a picture is drawn from: the view, the scripting plot and the
281
+ report all want the magnitude at about a screen's worth of columns,
282
+ and a million-sample record is not that. Time is cut into equal
283
+ slices and each column is the **largest** magnitude in its slice —
284
+ peak-hold, the same reading `decimate.peak_decimate` gives a curve
285
+ — because a scalogram's story is its ridges and a stride would land
286
+ between the very samples a transient's energy lives in, while a
287
+ mean would halve it. Under the budget nothing is held: the reading
288
+ is the magnitude itself, exactly.
289
+
290
+ Computed band by band and reduced as each band lands, so the whole
291
+ transform is never in memory at once: this is what a long record
292
+ goes through, and it costs `BAND_BYTES` a working array whatever
293
+ the length.
294
+
295
+ Parameters
296
+ ----------
297
+ values, sample_rate, frequencies, omega0
298
+ As for `scalogram`.
299
+ columns : int
300
+ The most columns to hand back.
242
301
 
302
+ Returns
303
+ -------
304
+ times, magnitude
305
+ `times` is each column's clock in seconds from the record's
306
+ start — the centre of its slice, so the first and last columns
307
+ sit half a slice inside the record's ends — and `magnitude` is
308
+ ``(len(frequencies), len(times))``, real.
309
+ """
310
+ record, wanted, samples = _prepared(values, sample_rate, frequencies)
311
+ step = max(1, -(-samples // int(columns)))
312
+ kept = -(-samples // step)
313
+ held = np.empty((wanted.size, kept), dtype=float)
314
+ # the ragged end is a slice like any other, kept rather than dropped:
315
+ # a burst in the last few samples is still a burst. Padding with
316
+ # zero cannot win a maximum of magnitudes.
317
+ pad = kept * step - samples
318
+ for rows, band in _bands(record, sample_rate, wanted, omega0):
319
+ magnitude = np.abs(band)
320
+ if pad:
321
+ magnitude = np.pad(magnitude, ((0, 0), (0, pad)))
322
+ held[rows] = magnitude.reshape(len(magnitude), kept, step).max(axis=2)
323
+ edges = np.arange(kept + 1) * step
324
+ edges[-1] = samples
325
+ times = (edges[:-1] + edges[1:] - 1) / 2.0 / float(sample_rate)
326
+ return times, held
327
+
328
+
329
+ def _prepared(values, sample_rate, frequencies):
330
+ """The record and the frequencies, checked once for both readings."""
243
331
  record = np.asarray(values)
244
332
  if np.iscomplexobj(record):
245
333
  raise ValueError('a scalogram is of a real record; this one is '
@@ -257,7 +345,18 @@ def scalogram(values: ArrayLike, sample_rate: float,
257
345
  f'frequencies must lie above zero and below Nyquist '
258
346
  f'({nyquist:g} Hz); asked for '
259
347
  f'{wanted.min():g} to {wanted.max():g} Hz')
348
+ return record, wanted, samples
349
+
350
+
351
+ def _bands(record, sample_rate, wanted, omega0):
352
+ """The transform, a band of rows at a time: yields (row slice, coefficients).
353
+
354
+ Each band's coefficients are already cut back to the record's
355
+ length; the caller stacks them or reduces them as it likes.
356
+ """
357
+ from scipy.fft import fft, ifft, next_fast_len
260
358
 
359
+ samples = record.size
261
360
  step = 1.0 / float(sample_rate)
262
361
  scales = scale_for(wanted, omega0)
263
362
 
@@ -287,27 +386,40 @@ def scalogram(values: ArrayLike, sample_rate: float,
287
386
  # angular frequencies of the FFT bins, negative above Nyquist — the
288
387
  # Morlet is analytic, so the negative half is what gets discarded
289
388
  omega = 2.0 * np.pi * np.fft.fftfreq(length, step)
290
-
291
- # (scales, samples), one row per frequency
292
- scaled = scales[:, None] * omega[None, :]
293
- # The Gaussian, analytic (the negative half of the spectrum is what
294
- # makes a wavelet complex), times two. The two is the whole
295
- # normalisation: a real tone of amplitude A puts A/2 in each half of
296
- # its spectrum, the analytic wavelet keeps one of them, and the peak
297
- # of the Morlet's own transform is 1 where it is tuned — so twice
298
- # that reads back A. No sqrt(scale) anywhere, which is exactly the
299
- # difference from the unit-energy convention.
300
- daughters = 2.0 * np.exp(-0.5 * (scaled - omega0) ** 2) * (
301
- omega[None, :] > 0.0)
302
- # workers=-1: the inverse is one transform per scale along axis 1
303
- # and they do not depend on each other, so this is the one place a
304
- # thread each is free. Measured 2.7x on a 512 000-sample record at
305
- # 97 scales, and bit-identical — scipy.fft takes the argument,
306
- # numpy.fft has nowhere to put it, which is most of why the
307
- # transform is computed through scipy at all.
308
- transformed = ifft(daughters * spectrum[None, :], axis=1, workers=-1)
309
- # back to the record: the pad was scaffolding, not signal
310
- return transformed[:, :samples]
389
+ # the analytic half, taken once: the Gaussian below is zero on the
390
+ # negative frequencies, so they need not be raised to it at all
391
+ positive = omega > 0.0
392
+ omega_pos = omega[positive]
393
+
394
+ rows_a_band = max(1, BAND_BYTES // (length * 16))
395
+ for first in range(0, scales.size, rows_a_band):
396
+ rows = slice(first, min(first + rows_a_band, scales.size))
397
+ # (band, samples), one row per frequency
398
+ scaled = scales[rows, None] * omega_pos[None, :]
399
+ # The Gaussian, analytic (the negative half of the spectrum is
400
+ # what makes a wavelet complex), times two. The two is the whole
401
+ # normalisation: a real tone of amplitude A puts A/2 in each
402
+ # half of its spectrum, the analytic wavelet keeps one of them,
403
+ # and the peak of the Morlet's own transform is 1 where it is
404
+ # tuned — so twice that reads back A. No sqrt(scale) anywhere,
405
+ # which is exactly the difference from the unit-energy
406
+ # convention.
407
+ product = np.zeros((rows.stop - rows.start, length), dtype=complex)
408
+ product[:, positive] = (
409
+ 2.0 * np.exp(-0.5 * (scaled - omega0) ** 2) * spectrum[positive])
410
+ del scaled
411
+ # workers=-1: the inverse is one transform per scale along axis
412
+ # 1 and they do not depend on each other, so this is the one
413
+ # place a thread each is free. Measured 2.7x on a 512 000-sample
414
+ # record at 97 scales, and bit-identical — scipy.fft takes the
415
+ # argument, numpy.fft has nowhere to put it, which is most of
416
+ # why the transform is computed through scipy at all.
417
+ # `overwrite_x`: the product is scaffolding, and letting the
418
+ # inverse work in place is one array fewer a band.
419
+ transformed = ifft(product, axis=1, workers=-1, overwrite_x=True)
420
+ del product
421
+ # back to the record: the pad was scaffolding, not signal
422
+ yield rows, transformed[:, :samples]
311
423
 
312
424
 
313
425
  def ridge(coefficients: ArrayLike, frequencies: ArrayLike) -> np.ndarray:
@@ -11733,9 +11733,13 @@ class MainWindow(QMainWindow):
11733
11733
  frequencies = frequencies[frequencies < rate / 2.0]
11734
11734
  if frequencies.size < 2:
11735
11735
  return 'no frequencies this record can carry in that range'
11736
- coefficients = wavelet_maths.scalogram(
11736
+ # the picture-sized reading, never the whole transform: a long
11737
+ # record's coefficients are gigabytes and its surface was what
11738
+ # crashed the app (Brandon, 2026-09-15). Each column is its
11739
+ # slice's peak, so a transient's ridge is not strided past.
11740
+ clock, magnitude = wavelet_maths.scalogram_peaks(
11737
11741
  values, rate, frequencies, settings['omega0'])
11738
- magnitude = np.abs(coefficients)
11742
+ clock = clock + float(history.abscissa[0])
11739
11743
 
11740
11744
  colors = resolve_theme(self.theme_name)
11741
11745
  us = self.unit_system
@@ -11758,8 +11762,7 @@ class MainWindow(QMainWindow):
11758
11762
  plotter.set_background(colors['scene_background'],
11759
11763
  top=colors['scene_background_top'])
11760
11764
  add_scalogram(
11761
- plotter, magnitude, np.asarray(history.abscissa),
11762
- frequencies, theme=self.theme_name,
11765
+ plotter, magnitude, clock, frequencies, theme=self.theme_name,
11763
11766
  omega0=settings['omega0'],
11764
11767
  time_label=f'time [{us.label_html("time")}]',
11765
11768
  level_label=f'{history.ordinate_dim[channel]}'
@@ -11774,8 +11777,7 @@ class MainWindow(QMainWindow):
11774
11777
  self.data_pane.graphics.clear()
11775
11778
  plot = self.data_pane.graphics.addPlot(row=0, col=0)
11776
11779
  scalogram_image(
11777
- plot, magnitude, np.asarray(history.abscissa), frequencies,
11778
- colors, label=history.ordinate_dim[channel],
11780
+ plot, magnitude, clock, frequencies, colors, label=history.ordinate_dim[channel],
11779
11781
  units=units, omega0=settings['omega0'],
11780
11782
  time_label=f'time [{us.label_html("time")}]')
11781
11783
 
@@ -2169,15 +2169,18 @@ def plot_scalogram(history: Any, channel: int = 0, *,
2169
2169
  raise ValueError('no frequencies this record can carry in that '
2170
2170
  f'range; it reaches {rate / 2.0:g} Hz')
2171
2171
  width = wavelet_maths.OMEGA0 if omega0 is None else omega0
2172
- magnitude = np.abs(wavelet_maths.scalogram(values, rate, frequencies,
2173
- width))
2172
+ # held to a picture's width, each column its slice's peak — the
2173
+ # app's own reading, and the one a long record survives
2174
+ clock, magnitude = wavelet_maths.scalogram_peaks(
2175
+ values, rate, frequencies, width)
2176
+ clock = clock + float(history.abscissa[0])
2174
2177
  colors = resolve_theme(theme)
2175
2178
 
2176
2179
  def build(widget: Any) -> Any:
2177
2180
  widget.clear()
2178
2181
  scalogram_image(
2179
- widget.addPlot(row=0, col=0), magnitude,
2180
- np.asarray(history.abscissa), frequencies, colors,
2182
+ widget.addPlot(row=0, col=0), magnitude, clock, frequencies,
2183
+ colors,
2181
2184
  label=history.ordinate_dim[channel],
2182
2185
  units=history.ordinate_unit[channel] or '', omega0=width)
2183
2186
 
@@ -862,21 +862,15 @@ def _scalogram_figure(block, source, us, caption):
862
862
  frequencies = frequencies[frequencies < rate / 2.0]
863
863
  if frequencies.size < 2:
864
864
  return None
865
- magnitude = np.abs(wavelet.scalogram(values, rate, frequencies))
866
-
867
- # peak-hold down to the columns a figure can carry: the ridges are
868
- # the story, and a stride would land between the very samples a
869
- # transient's energy lives in
870
- columns = magnitude.shape[1]
865
+ # peak-held down to the columns a figure can carry, by the reading
866
+ # the app's view draws from: the ridges are the story, and a stride
867
+ # would land between the very samples a transient's energy lives in
868
+ times, magnitude = wavelet.scalogram_peaks(
869
+ values, rate, frequencies, columns=SCALOGRAM_COLUMNS)
871
870
  note = ''
872
- if columns > SCALOGRAM_COLUMNS:
873
- step = -(-columns // SCALOGRAM_COLUMNS)
874
- pad = (-columns) % step
875
- held = np.pad(magnitude, ((0, 0), (0, pad)), constant_values=0.0)
876
- magnitude = held.reshape(magnitude.shape[0], -1, step).max(axis=2)
871
+ if magnitude.shape[1] < len(values):
877
872
  note = (f'time thinned to {magnitude.shape[1]} columns of '
878
- f'{columns}, keeping each bin\'s peak')
879
- times = np.linspace(0.0, duration, magnitude.shape[1])
873
+ f'{len(values)}, keeping each bin\'s peak')
880
874
 
881
875
  sx, sy, _sz = STAGE
882
876
  z1 = float(magnitude.max()) or 1.0
@@ -22,6 +22,7 @@ holding page's copy). Its shape:
22
22
  from __future__ import annotations
23
23
 
24
24
  import json
25
+ import ssl
25
26
  from typing import Any
26
27
  from urllib.request import Request, urlopen
27
28
 
@@ -68,6 +69,30 @@ def newer(available: str, running: str = __version__) -> bool:
68
69
  return parse_version(available) > parse_version(running)
69
70
 
70
71
 
72
+ def trust_store() -> ssl.SSLContext:
73
+ """The TLS context the check verifies visualdynamics.org with.
74
+
75
+ A packaged build carries its own OpenSSL, and the one the wheels
76
+ bring was built elsewhere: its compiled-in certificate directory
77
+ is a folder in the builder's own home (read off the library with
78
+ `strings`, 2026-09-15) and exists on nobody else's machine, so the
79
+ default context trusts nothing and every HTTPS request fails —
80
+ which the check reported as "could not reach visualdynamics.org"
81
+ on a Mac that was online (Brandon, 2026-09-15). When the default store is empty, the
82
+ Mozilla bundle certifi ships is loaded instead; it is already in
83
+ every package as netCDF4's dependency, and is what the Python
84
+ ecosystem uses for exactly this.
85
+ """
86
+ context = ssl.create_default_context()
87
+ if not context.get_ca_certs():
88
+ try:
89
+ import certifi
90
+ context.load_verify_locations(certifi.where())
91
+ except Exception: # noqa: BLE001, S110 — no bundle either: the fetch fails as before
92
+ pass
93
+ return context
94
+
95
+
71
96
  def fetch(url: str = MANIFEST, timeout: float = TIMEOUT) -> dict[str, Any] | None:
72
97
  """The manifest as published, or None when it cannot be had.
73
98
 
@@ -79,7 +104,8 @@ def fetch(url: str = MANIFEST, timeout: float = TIMEOUT) -> dict[str, Any] | Non
79
104
  try:
80
105
  request = Request(url, headers={
81
106
  'User-Agent': f'VisualDynamics/{__version__}'})
82
- with urlopen(request, timeout=timeout) as response:
107
+ with urlopen(request, timeout=timeout,
108
+ context=trust_store()) as response:
83
109
  manifest = json.loads(response.read(64_000).decode('utf-8'))
84
110
  except Exception: # noqa: BLE001 — see the docstring: every
85
111
  return None # failure here means 'no manifest', never a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visualdynamics
3
- Version: 0.1.0a2
3
+ Version: 0.1.0a3
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
@@ -22,6 +22,7 @@ Requires-Python: >=3.12
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: numpy
25
+ Requires-Dist: certifi
25
26
  Requires-Dist: scipy
26
27
  Requires-Dist: h5py
27
28
  Requires-Dist: netCDF4