oscura 0.7.0__py3-none-any.whl → 0.10.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.
Files changed (175) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/eye/__init__.py +5 -1
  7. oscura/analyzers/eye/generation.py +501 -0
  8. oscura/analyzers/jitter/__init__.py +6 -6
  9. oscura/analyzers/jitter/timing.py +419 -0
  10. oscura/analyzers/patterns/__init__.py +94 -0
  11. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  12. oscura/analyzers/power/__init__.py +35 -12
  13. oscura/analyzers/power/basic.py +3 -3
  14. oscura/analyzers/power/soa.py +1 -1
  15. oscura/analyzers/power/switching.py +3 -3
  16. oscura/analyzers/signal_classification.py +529 -0
  17. oscura/analyzers/signal_integrity/sparams.py +3 -3
  18. oscura/analyzers/statistics/__init__.py +4 -0
  19. oscura/analyzers/statistics/basic.py +152 -0
  20. oscura/analyzers/statistics/correlation.py +47 -6
  21. oscura/analyzers/validation.py +1 -1
  22. oscura/analyzers/waveform/__init__.py +2 -0
  23. oscura/analyzers/waveform/measurements.py +329 -163
  24. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  25. oscura/analyzers/waveform/spectral.py +498 -54
  26. oscura/api/dsl/commands.py +15 -6
  27. oscura/api/server/templates/base.html +137 -146
  28. oscura/api/server/templates/export.html +84 -110
  29. oscura/api/server/templates/home.html +248 -267
  30. oscura/api/server/templates/protocols.html +44 -48
  31. oscura/api/server/templates/reports.html +27 -35
  32. oscura/api/server/templates/session_detail.html +68 -78
  33. oscura/api/server/templates/sessions.html +62 -72
  34. oscura/api/server/templates/waveforms.html +54 -64
  35. oscura/automotive/__init__.py +1 -1
  36. oscura/automotive/can/session.py +1 -1
  37. oscura/automotive/dbc/generator.py +638 -23
  38. oscura/automotive/dtc/data.json +102 -17
  39. oscura/automotive/uds/decoder.py +99 -6
  40. oscura/cli/analyze.py +8 -2
  41. oscura/cli/batch.py +36 -5
  42. oscura/cli/characterize.py +18 -4
  43. oscura/cli/export.py +47 -5
  44. oscura/cli/main.py +2 -0
  45. oscura/cli/onboarding/wizard.py +10 -6
  46. oscura/cli/pipeline.py +585 -0
  47. oscura/cli/visualize.py +6 -4
  48. oscura/convenience.py +400 -32
  49. oscura/core/config/loader.py +0 -1
  50. oscura/core/measurement_result.py +286 -0
  51. oscura/core/progress.py +1 -1
  52. oscura/core/schemas/device_mapping.json +8 -2
  53. oscura/core/schemas/packet_format.json +24 -4
  54. oscura/core/schemas/protocol_definition.json +12 -2
  55. oscura/core/types.py +300 -199
  56. oscura/correlation/multi_protocol.py +1 -1
  57. oscura/export/legacy/__init__.py +11 -0
  58. oscura/export/legacy/wav.py +75 -0
  59. oscura/exporters/__init__.py +19 -0
  60. oscura/exporters/wireshark.py +809 -0
  61. oscura/hardware/acquisition/file.py +5 -19
  62. oscura/hardware/acquisition/saleae.py +10 -10
  63. oscura/hardware/acquisition/socketcan.py +4 -6
  64. oscura/hardware/acquisition/synthetic.py +1 -5
  65. oscura/hardware/acquisition/visa.py +6 -6
  66. oscura/hardware/security/side_channel_detector.py +5 -508
  67. oscura/inference/message_format.py +686 -1
  68. oscura/jupyter/display.py +2 -2
  69. oscura/jupyter/magic.py +3 -3
  70. oscura/loaders/__init__.py +17 -12
  71. oscura/loaders/binary.py +1 -1
  72. oscura/loaders/chipwhisperer.py +1 -2
  73. oscura/loaders/configurable.py +1 -1
  74. oscura/loaders/csv_loader.py +2 -2
  75. oscura/loaders/hdf5_loader.py +1 -1
  76. oscura/loaders/lazy.py +6 -1
  77. oscura/loaders/mmap_loader.py +0 -1
  78. oscura/loaders/numpy_loader.py +8 -7
  79. oscura/loaders/preprocessing.py +3 -5
  80. oscura/loaders/rigol.py +21 -7
  81. oscura/loaders/sigrok.py +2 -5
  82. oscura/loaders/tdms.py +3 -2
  83. oscura/loaders/tektronix.py +38 -32
  84. oscura/loaders/tss.py +20 -27
  85. oscura/loaders/vcd.py +13 -8
  86. oscura/loaders/wav.py +1 -6
  87. oscura/pipeline/__init__.py +76 -0
  88. oscura/pipeline/handlers/__init__.py +165 -0
  89. oscura/pipeline/handlers/analyzers.py +1045 -0
  90. oscura/pipeline/handlers/decoders.py +899 -0
  91. oscura/pipeline/handlers/exporters.py +1103 -0
  92. oscura/pipeline/handlers/filters.py +891 -0
  93. oscura/pipeline/handlers/loaders.py +640 -0
  94. oscura/pipeline/handlers/transforms.py +768 -0
  95. oscura/reporting/__init__.py +88 -1
  96. oscura/reporting/automation.py +348 -0
  97. oscura/reporting/citations.py +374 -0
  98. oscura/reporting/core.py +54 -0
  99. oscura/reporting/formatting/__init__.py +11 -0
  100. oscura/reporting/formatting/measurements.py +320 -0
  101. oscura/reporting/html.py +57 -0
  102. oscura/reporting/interpretation.py +431 -0
  103. oscura/reporting/summary.py +329 -0
  104. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  105. oscura/reporting/visualization.py +542 -0
  106. oscura/side_channel/__init__.py +38 -57
  107. oscura/utils/builders/signal_builder.py +5 -5
  108. oscura/utils/comparison/compare.py +7 -9
  109. oscura/utils/comparison/golden.py +1 -1
  110. oscura/utils/filtering/convenience.py +2 -2
  111. oscura/utils/math/arithmetic.py +38 -62
  112. oscura/utils/math/interpolation.py +20 -20
  113. oscura/utils/pipeline/__init__.py +4 -17
  114. oscura/utils/progressive.py +1 -4
  115. oscura/utils/triggering/edge.py +1 -1
  116. oscura/utils/triggering/pattern.py +2 -2
  117. oscura/utils/triggering/pulse.py +2 -2
  118. oscura/utils/triggering/window.py +3 -3
  119. oscura/validation/hil_testing.py +11 -11
  120. oscura/visualization/__init__.py +47 -284
  121. oscura/visualization/batch.py +160 -0
  122. oscura/visualization/plot.py +542 -53
  123. oscura/visualization/styles.py +184 -318
  124. oscura/workflows/__init__.py +2 -0
  125. oscura/workflows/batch/advanced.py +1 -1
  126. oscura/workflows/batch/aggregate.py +7 -8
  127. oscura/workflows/complete_re.py +251 -23
  128. oscura/workflows/digital.py +27 -4
  129. oscura/workflows/multi_trace.py +136 -17
  130. oscura/workflows/waveform.py +788 -0
  131. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  132. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
  133. oscura/side_channel/dpa.py +0 -1025
  134. oscura/utils/optimization/__init__.py +0 -19
  135. oscura/utils/optimization/parallel.py +0 -443
  136. oscura/utils/optimization/search.py +0 -532
  137. oscura/utils/pipeline/base.py +0 -338
  138. oscura/utils/pipeline/composition.py +0 -248
  139. oscura/utils/pipeline/parallel.py +0 -449
  140. oscura/utils/pipeline/pipeline.py +0 -375
  141. oscura/utils/search/__init__.py +0 -16
  142. oscura/utils/search/anomaly.py +0 -424
  143. oscura/utils/search/context.py +0 -294
  144. oscura/utils/search/pattern.py +0 -288
  145. oscura/utils/storage/__init__.py +0 -61
  146. oscura/utils/storage/database.py +0 -1166
  147. oscura/visualization/accessibility.py +0 -526
  148. oscura/visualization/annotations.py +0 -371
  149. oscura/visualization/axis_scaling.py +0 -305
  150. oscura/visualization/colors.py +0 -451
  151. oscura/visualization/digital.py +0 -436
  152. oscura/visualization/eye.py +0 -571
  153. oscura/visualization/histogram.py +0 -281
  154. oscura/visualization/interactive.py +0 -1035
  155. oscura/visualization/jitter.py +0 -1042
  156. oscura/visualization/keyboard.py +0 -394
  157. oscura/visualization/layout.py +0 -400
  158. oscura/visualization/optimization.py +0 -1079
  159. oscura/visualization/palettes.py +0 -446
  160. oscura/visualization/power.py +0 -508
  161. oscura/visualization/power_extended.py +0 -955
  162. oscura/visualization/presets.py +0 -469
  163. oscura/visualization/protocols.py +0 -1246
  164. oscura/visualization/render.py +0 -223
  165. oscura/visualization/rendering.py +0 -444
  166. oscura/visualization/reverse_engineering.py +0 -838
  167. oscura/visualization/signal_integrity.py +0 -989
  168. oscura/visualization/specialized.py +0 -643
  169. oscura/visualization/spectral.py +0 -1226
  170. oscura/visualization/thumbnails.py +0 -340
  171. oscura/visualization/time_axis.py +0 -351
  172. oscura/visualization/waveform.py +0 -454
  173. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  174. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  175. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,320 @@
1
+ """Measurement formatting with intelligent SI prefix scaling and unit display.
2
+
3
+ This module provides automatic formatting for measurement dictionaries,
4
+ converting raw numeric values into human-readable strings with proper
5
+ SI prefixes and units.
6
+
7
+ Example:
8
+ >>> from oscura.reporting.formatting.measurements import format_measurement
9
+ >>> measurement = {"value": 2.3e-9, "unit": "s"}
10
+ >>> format_measurement(measurement)
11
+ '2.30 ns'
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from oscura.reporting.formatting.numbers import NumberFormatter
20
+
21
+
22
+ @dataclass
23
+ class MeasurementFormatter:
24
+ """Format measurement dictionaries with intelligent number formatting and units.
25
+
26
+ This class provides centralized, professional formatting for measurement data,
27
+ automatically applying SI prefixes and units for optimal readability.
28
+
29
+ Attributes:
30
+ number_formatter: NumberFormatter instance for value formatting.
31
+ default_sig_figs: Default significant figures for display.
32
+ show_units: Whether to append units to formatted values.
33
+ show_specs: Whether to include specification comparison in output.
34
+ """
35
+
36
+ number_formatter: NumberFormatter | None = None
37
+ default_sig_figs: int = 4
38
+ show_units: bool = True
39
+ show_specs: bool = False
40
+
41
+ def __post_init__(self) -> None:
42
+ """Initialize number formatter if not provided."""
43
+ if self.number_formatter is None:
44
+ self.number_formatter = NumberFormatter(sig_figs=self.default_sig_figs)
45
+
46
+ def format_single(self, value: float, unit: str = "") -> str:
47
+ """Format single measurement value with SI prefix and unit.
48
+
49
+ Args:
50
+ value: Numeric value to format.
51
+ unit: Unit string (e.g., "s", "Hz", "V", "ratio", "%").
52
+
53
+ Returns:
54
+ Formatted string with SI prefix and unit (e.g., "2.30 ns").
55
+
56
+ Example:
57
+ >>> formatter = MeasurementFormatter()
58
+ >>> formatter.format_single(2.3e-9, "s")
59
+ '2.30 ns'
60
+ >>> formatter.format_single(440.0, "Hz")
61
+ '440.0 Hz'
62
+ >>> formatter.format_single(0.5, "ratio")
63
+ '50.00 %'
64
+ """
65
+ assert self.number_formatter is not None
66
+
67
+ # Handle ratio values (duty_cycle, overshoot, undershoot)
68
+ # These are stored as fractions (0.5) but should display as percentages (50%)
69
+ if unit == "ratio":
70
+ percentage_value = value * 100
71
+ formatted = self.number_formatter.format(percentage_value, "")
72
+ return f"{formatted} %" if self.show_units else formatted
73
+
74
+ # Handle percentages (THD, etc.) that are already in percentage units
75
+ if unit == "%":
76
+ formatted = self.number_formatter.format(value, "")
77
+ return f"{formatted} %" if self.show_units else formatted
78
+
79
+ # Handle dimensionless values
80
+ if unit == "":
81
+ formatted = self.number_formatter.format(value, "")
82
+ return formatted
83
+
84
+ # Use NumberFormatter's built-in SI prefix support for other units
85
+ formatted = self.number_formatter.format(value, unit)
86
+ return formatted if self.show_units else formatted.replace(unit, "").strip()
87
+
88
+ def format_measurement(self, measurement: dict[str, Any]) -> str:
89
+ """Format complete measurement dict with value, unit, spec, status.
90
+
91
+ Supports both legacy format and MeasurementResult format.
92
+
93
+ Args:
94
+ measurement: Dictionary containing:
95
+ Legacy format:
96
+ - value: numeric measurement value
97
+ - unit: unit string (optional)
98
+ - spec: specification limit (optional)
99
+ - spec_type: "max", "min", or "exact" (optional)
100
+ - passed: boolean pass/fail status (optional)
101
+ MeasurementResult format:
102
+ - value: float | None
103
+ - unit: str
104
+ - applicable: bool
105
+ - reason: str | None
106
+ - display: str (pre-formatted)
107
+
108
+ Returns:
109
+ Formatted string representation of measurement.
110
+
111
+ Example:
112
+ >>> formatter = MeasurementFormatter(show_specs=True)
113
+ >>> measurement = {
114
+ ... "value": 2.3e-9,
115
+ ... "unit": "s",
116
+ ... "spec": 10e-9,
117
+ ... "spec_type": "max",
118
+ ... "passed": True
119
+ ... }
120
+ >>> formatter.format_measurement(measurement)
121
+ '2.30 ns (spec: < 10.0 ns) ✓'
122
+
123
+ >>> # MeasurementResult format with inapplicable measurement
124
+ >>> inapplicable = {
125
+ ... "value": None,
126
+ ... "unit": "Hz",
127
+ ... "applicable": False,
128
+ ... "reason": "Aperiodic signal",
129
+ ... "display": "N/A"
130
+ ... }
131
+ >>> formatter.format_measurement(inapplicable)
132
+ 'N/A (Aperiodic signal)'
133
+ """
134
+ # Check if this is MeasurementResult format
135
+ if "applicable" in measurement:
136
+ if not measurement["applicable"]:
137
+ # Inapplicable measurement - show N/A with reason
138
+ reason = measurement.get("reason")
139
+ if reason:
140
+ return f"N/A ({reason})"
141
+ return "N/A"
142
+
143
+ # Applicable measurement - use pre-formatted display if available
144
+ if "display" in measurement:
145
+ formatted = str(measurement["display"])
146
+ else:
147
+ # Fallback to manual formatting
148
+ value = measurement.get("value")
149
+ unit = measurement.get("unit", "")
150
+ if value is None:
151
+ return "N/A"
152
+ formatted = self.format_single(value, unit)
153
+ else:
154
+ # Legacy format
155
+ value = measurement.get("value")
156
+ unit = measurement.get("unit", "")
157
+
158
+ if value is None:
159
+ return "N/A"
160
+
161
+ if not isinstance(value, (int, float)):
162
+ return str(value)
163
+
164
+ # Format the value
165
+ formatted = self.format_single(value, unit)
166
+
167
+ # Add spec comparison if requested
168
+ if self.show_specs and "spec" in measurement:
169
+ spec = measurement["spec"]
170
+ spec_type = measurement.get("spec_type", "exact")
171
+ spec_formatted = self.format_single(spec, measurement.get("unit", ""))
172
+
173
+ if spec_type == "max":
174
+ formatted += f" (spec: < {spec_formatted})"
175
+ elif spec_type == "min":
176
+ formatted += f" (spec: > {spec_formatted})"
177
+ else: # exact
178
+ formatted += f" (spec: {spec_formatted})"
179
+
180
+ # Add pass/fail indicator
181
+ if "passed" in measurement:
182
+ formatted += " ✓" if measurement["passed"] else " ✗"
183
+
184
+ return formatted
185
+
186
+ def format_measurement_dict(
187
+ self, measurements: dict[str, dict[str, Any]], html: bool = True
188
+ ) -> str:
189
+ """Format dictionary of measurements as formatted text or HTML.
190
+
191
+ Args:
192
+ measurements: Dictionary mapping parameter names to measurement dicts.
193
+ html: If True, return HTML list; if False, return plain text.
194
+
195
+ Returns:
196
+ HTML unordered list or multi-line string with formatted measurements.
197
+
198
+ Example:
199
+ >>> formatter = MeasurementFormatter()
200
+ >>> measurements = {
201
+ ... "rise_time": {"value": 2.3e-9, "unit": "s"},
202
+ ... "frequency": {"value": 440.0, "unit": "Hz"}
203
+ ... }
204
+ >>> print(formatter.format_measurement_dict(measurements, html=False))
205
+ Rise Time: 2.30 ns
206
+ Frequency: 440.0 Hz
207
+ """
208
+ lines = []
209
+ for key, measurement in measurements.items():
210
+ # Convert snake_case to Title Case
211
+ display_name = key.replace("_", " ").title()
212
+ formatted_value = self.format_measurement(measurement)
213
+
214
+ if html:
215
+ lines.append(f"<li><strong>{display_name}:</strong> {formatted_value}</li>")
216
+ else:
217
+ lines.append(f"{display_name}: {formatted_value}")
218
+
219
+ if html:
220
+ return f"<ul>\n{''.join(lines)}\n</ul>"
221
+ else:
222
+ return "\n".join(lines)
223
+
224
+ def to_display_dict(self, measurements: dict[str, dict[str, Any]]) -> dict[str, str]:
225
+ """Convert measurements to display-ready string dictionary.
226
+
227
+ Args:
228
+ measurements: Dictionary mapping parameter names to measurement dicts.
229
+
230
+ Returns:
231
+ Dictionary mapping parameter names to formatted value strings.
232
+
233
+ Example:
234
+ >>> formatter = MeasurementFormatter()
235
+ >>> measurements = {"rise_time": {"value": 2.3e-9, "unit": "s"}}
236
+ >>> formatter.to_display_dict(measurements)
237
+ {'rise_time': '2.30 ns'}
238
+ """
239
+ return {key: self.format_measurement(meas) for key, meas in measurements.items()}
240
+
241
+
242
+ def format_measurement(measurement: dict[str, Any], sig_figs: int = 4) -> str:
243
+ """Quick format single measurement dict.
244
+
245
+ Convenience function for formatting a single measurement without
246
+ creating a MeasurementFormatter instance.
247
+
248
+ Args:
249
+ measurement: Measurement dictionary with value and optional unit.
250
+ sig_figs: Number of significant figures.
251
+
252
+ Returns:
253
+ Formatted measurement string.
254
+
255
+ Example:
256
+ >>> format_measurement({"value": 2.3e-9, "unit": "s"})
257
+ '2.300 ns'
258
+ """
259
+ formatter = MeasurementFormatter(number_formatter=NumberFormatter(sig_figs=sig_figs))
260
+ return formatter.format_measurement(measurement)
261
+
262
+
263
+ def format_measurement_dict(
264
+ measurements: dict[str, dict[str, Any]], sig_figs: int = 4, html: bool = True
265
+ ) -> str:
266
+ """Quick format measurement dictionary.
267
+
268
+ Convenience function for formatting multiple measurements without
269
+ creating a MeasurementFormatter instance.
270
+
271
+ Args:
272
+ measurements: Dictionary mapping names to measurement dicts.
273
+ sig_figs: Number of significant figures.
274
+ html: If True, return HTML list; if False, return plain text.
275
+
276
+ Returns:
277
+ HTML unordered list or multi-line formatted string.
278
+
279
+ Example:
280
+ >>> measurements = {
281
+ ... "rise_time": {"value": 2.3e-9, "unit": "s"},
282
+ ... "frequency": {"value": 440.0, "unit": "Hz"}
283
+ ... }
284
+ >>> print(format_measurement_dict(measurements, html=False))
285
+ Rise Time: 2.300 ns
286
+ Frequency: 440.0 Hz
287
+ """
288
+ formatter = MeasurementFormatter(number_formatter=NumberFormatter(sig_figs=sig_figs))
289
+ return formatter.format_measurement_dict(measurements, html=html)
290
+
291
+
292
+ def convert_to_measurement_dict(
293
+ raw_measurements: dict[str, float], unit_map: dict[str, str]
294
+ ) -> dict[str, dict[str, Any]]:
295
+ """Convert raw measurement dictionary to structured measurement format.
296
+
297
+ Helper function to convert simple {name: value} dictionaries into
298
+ the full measurement format with units.
299
+
300
+ Args:
301
+ raw_measurements: Dictionary mapping parameter names to numeric values.
302
+ unit_map: Dictionary mapping parameter names to unit strings.
303
+
304
+ Returns:
305
+ Dictionary in measurement format with value and unit fields.
306
+
307
+ Example:
308
+ >>> raw = {"rise_time": 2.3e-9, "frequency": 440.0}
309
+ >>> units = {"rise_time": "s", "frequency": "Hz"}
310
+ >>> convert_to_measurement_dict(raw, units)
311
+ {
312
+ 'rise_time': {'value': 2.3e-09, 'unit': 's'},
313
+ 'frequency': {'value': 440.0, 'unit': 'Hz'}
314
+ }
315
+ """
316
+ return {
317
+ key: {"value": value, "unit": unit_map.get(key, "")}
318
+ for key, value in raw_measurements.items()
319
+ if isinstance(value, (int, float))
320
+ }
oscura/reporting/html.py CHANGED
@@ -548,6 +548,9 @@ def _render_section_header(section: Any, collapsible: bool) -> str:
548
548
  def _render_section_content(section: Any) -> str:
549
549
  """Render section content (text, tables, figures)."""
550
550
  if isinstance(section.content, str):
551
+ # Check if content is already HTML (starts with HTML tag)
552
+ if section.content.strip().startswith("<"):
553
+ return section.content # Return HTML as-is
551
554
  return f"<p>{section.content}</p>"
552
555
 
553
556
  if isinstance(section.content, list):
@@ -643,6 +646,60 @@ def _figure_to_html(figure: dict[str, Any]) -> str:
643
646
  return "".join(html_parts)
644
647
 
645
648
 
649
+ def embed_plots(
650
+ html_content: str,
651
+ plots: dict[str, str],
652
+ *,
653
+ section_title: str = "Visualizations",
654
+ insert_location: str = "before_closing_div",
655
+ ) -> str:
656
+ """Embed base64-encoded plots into HTML content.
657
+
658
+ Args:
659
+ html_content: Original HTML content.
660
+ plots: Dictionary mapping plot names to base64 image strings.
661
+ Values should be either base64 strings or data URIs.
662
+ section_title: Title for the plots section.
663
+ insert_location: Where to insert plots: "before_closing_div",
664
+ "before_closing_body", or "append".
665
+
666
+ Returns:
667
+ Enhanced HTML with embedded plots.
668
+
669
+ Example:
670
+ >>> plots = {"waveform": "data:image/png;base64,iVBOR...", "fft": "..."}
671
+ >>> enhanced_html = embed_plots(html_content, plots)
672
+ """
673
+ # Build plots HTML section
674
+ plot_html = "\n\n<!-- EMBEDDED PLOTS -->\n"
675
+ plot_html += f"<section id='plots'>\n<h2>{section_title}</h2>\n"
676
+
677
+ for plot_name, plot_data in plots.items():
678
+ # Ensure data URI format
679
+ if not plot_data.startswith("data:"):
680
+ plot_data = f"data:image/png;base64,{plot_data}"
681
+
682
+ plot_html += f"\n<h3>{plot_name.replace('_', ' ').title()}</h3>\n"
683
+ plot_html += (
684
+ f'<img src="{plot_data}" alt="{plot_name}" '
685
+ 'style="max-width:100%; height:auto; border:1px solid #ddd; '
686
+ 'border-radius:4px; margin:10px 0;">\n'
687
+ )
688
+
689
+ plot_html += "</section>\n\n"
690
+
691
+ # Insert at specified location
692
+ if insert_location == "before_closing_div" and "</div>" in html_content:
693
+ html_content = html_content.replace("</div>", f"{plot_html}</div>", 1)
694
+ elif insert_location == "before_closing_body" and "</body>" in html_content:
695
+ html_content = html_content.replace("</body>", f"{plot_html}</body>")
696
+ else:
697
+ # Append to end
698
+ html_content += plot_html
699
+
700
+ return html_content
701
+
702
+
646
703
  def save_html_report(
647
704
  report: Report,
648
705
  path: str | Path,