oscura 0.6.0__py3-none-any.whl → 0.8.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 (38) hide show
  1. oscura/__init__.py +1 -1
  2. oscura/analyzers/eye/__init__.py +5 -1
  3. oscura/analyzers/eye/generation.py +501 -0
  4. oscura/analyzers/jitter/__init__.py +6 -6
  5. oscura/analyzers/jitter/timing.py +419 -0
  6. oscura/analyzers/patterns/__init__.py +28 -0
  7. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  8. oscura/analyzers/power/__init__.py +35 -12
  9. oscura/analyzers/statistics/__init__.py +4 -0
  10. oscura/analyzers/statistics/basic.py +149 -0
  11. oscura/analyzers/statistics/correlation.py +47 -6
  12. oscura/analyzers/waveform/__init__.py +2 -0
  13. oscura/analyzers/waveform/measurements.py +145 -23
  14. oscura/analyzers/waveform/spectral.py +361 -8
  15. oscura/automotive/__init__.py +1 -1
  16. oscura/core/config/loader.py +0 -1
  17. oscura/core/types.py +108 -0
  18. oscura/loaders/__init__.py +12 -4
  19. oscura/loaders/tss.py +456 -0
  20. oscura/reporting/__init__.py +88 -1
  21. oscura/reporting/automation.py +348 -0
  22. oscura/reporting/citations.py +374 -0
  23. oscura/reporting/core.py +54 -0
  24. oscura/reporting/formatting/__init__.py +11 -0
  25. oscura/reporting/formatting/measurements.py +279 -0
  26. oscura/reporting/html.py +57 -0
  27. oscura/reporting/interpretation.py +431 -0
  28. oscura/reporting/summary.py +329 -0
  29. oscura/reporting/visualization.py +542 -0
  30. oscura/visualization/__init__.py +2 -1
  31. oscura/visualization/batch.py +521 -0
  32. oscura/workflows/__init__.py +2 -0
  33. oscura/workflows/waveform.py +783 -0
  34. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/METADATA +37 -19
  35. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/RECORD +38 -26
  36. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/WHEEL +0 -0
  37. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/entry_points.txt +0 -0
  38. {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,279 @@
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
+ Args:
92
+ measurement: Dictionary containing:
93
+ - value: numeric measurement value
94
+ - unit: unit string (optional)
95
+ - spec: specification limit (optional)
96
+ - spec_type: "max", "min", or "exact" (optional)
97
+ - passed: boolean pass/fail status (optional)
98
+
99
+ Returns:
100
+ Formatted string representation of measurement.
101
+
102
+ Example:
103
+ >>> formatter = MeasurementFormatter(show_specs=True)
104
+ >>> measurement = {
105
+ ... "value": 2.3e-9,
106
+ ... "unit": "s",
107
+ ... "spec": 10e-9,
108
+ ... "spec_type": "max",
109
+ ... "passed": True
110
+ ... }
111
+ >>> formatter.format_measurement(measurement)
112
+ '2.30 ns (spec: < 10.0 ns) ✓'
113
+ """
114
+ value = measurement.get("value")
115
+ unit = measurement.get("unit", "")
116
+
117
+ if value is None:
118
+ return "N/A"
119
+
120
+ if not isinstance(value, (int, float)):
121
+ return str(value)
122
+
123
+ # Format the value
124
+ formatted = self.format_single(value, unit)
125
+
126
+ # Add spec comparison if requested
127
+ if self.show_specs and "spec" in measurement:
128
+ spec = measurement["spec"]
129
+ spec_type = measurement.get("spec_type", "exact")
130
+ spec_formatted = self.format_single(spec, unit)
131
+
132
+ if spec_type == "max":
133
+ formatted += f" (spec: < {spec_formatted})"
134
+ elif spec_type == "min":
135
+ formatted += f" (spec: > {spec_formatted})"
136
+ else: # exact
137
+ formatted += f" (spec: {spec_formatted})"
138
+
139
+ # Add pass/fail indicator
140
+ if "passed" in measurement:
141
+ formatted += " ✓" if measurement["passed"] else " ✗"
142
+
143
+ return formatted
144
+
145
+ def format_measurement_dict(
146
+ self, measurements: dict[str, dict[str, Any]], html: bool = True
147
+ ) -> str:
148
+ """Format dictionary of measurements as formatted text or HTML.
149
+
150
+ Args:
151
+ measurements: Dictionary mapping parameter names to measurement dicts.
152
+ html: If True, return HTML list; if False, return plain text.
153
+
154
+ Returns:
155
+ HTML unordered list or multi-line string with formatted measurements.
156
+
157
+ Example:
158
+ >>> formatter = MeasurementFormatter()
159
+ >>> measurements = {
160
+ ... "rise_time": {"value": 2.3e-9, "unit": "s"},
161
+ ... "frequency": {"value": 440.0, "unit": "Hz"}
162
+ ... }
163
+ >>> print(formatter.format_measurement_dict(measurements, html=False))
164
+ Rise Time: 2.30 ns
165
+ Frequency: 440.0 Hz
166
+ """
167
+ lines = []
168
+ for key, measurement in measurements.items():
169
+ # Convert snake_case to Title Case
170
+ display_name = key.replace("_", " ").title()
171
+ formatted_value = self.format_measurement(measurement)
172
+
173
+ if html:
174
+ lines.append(f"<li><strong>{display_name}:</strong> {formatted_value}</li>")
175
+ else:
176
+ lines.append(f"{display_name}: {formatted_value}")
177
+
178
+ if html:
179
+ return f"<ul>\n{''.join(lines)}\n</ul>"
180
+ else:
181
+ return "\n".join(lines)
182
+
183
+ def to_display_dict(self, measurements: dict[str, dict[str, Any]]) -> dict[str, str]:
184
+ """Convert measurements to display-ready string dictionary.
185
+
186
+ Args:
187
+ measurements: Dictionary mapping parameter names to measurement dicts.
188
+
189
+ Returns:
190
+ Dictionary mapping parameter names to formatted value strings.
191
+
192
+ Example:
193
+ >>> formatter = MeasurementFormatter()
194
+ >>> measurements = {"rise_time": {"value": 2.3e-9, "unit": "s"}}
195
+ >>> formatter.to_display_dict(measurements)
196
+ {'rise_time': '2.30 ns'}
197
+ """
198
+ return {key: self.format_measurement(meas) for key, meas in measurements.items()}
199
+
200
+
201
+ def format_measurement(measurement: dict[str, Any], sig_figs: int = 4) -> str:
202
+ """Quick format single measurement dict.
203
+
204
+ Convenience function for formatting a single measurement without
205
+ creating a MeasurementFormatter instance.
206
+
207
+ Args:
208
+ measurement: Measurement dictionary with value and optional unit.
209
+ sig_figs: Number of significant figures.
210
+
211
+ Returns:
212
+ Formatted measurement string.
213
+
214
+ Example:
215
+ >>> format_measurement({"value": 2.3e-9, "unit": "s"})
216
+ '2.300 ns'
217
+ """
218
+ formatter = MeasurementFormatter(number_formatter=NumberFormatter(sig_figs=sig_figs))
219
+ return formatter.format_measurement(measurement)
220
+
221
+
222
+ def format_measurement_dict(
223
+ measurements: dict[str, dict[str, Any]], sig_figs: int = 4, html: bool = True
224
+ ) -> str:
225
+ """Quick format measurement dictionary.
226
+
227
+ Convenience function for formatting multiple measurements without
228
+ creating a MeasurementFormatter instance.
229
+
230
+ Args:
231
+ measurements: Dictionary mapping names to measurement dicts.
232
+ sig_figs: Number of significant figures.
233
+ html: If True, return HTML list; if False, return plain text.
234
+
235
+ Returns:
236
+ HTML unordered list or multi-line formatted string.
237
+
238
+ Example:
239
+ >>> measurements = {
240
+ ... "rise_time": {"value": 2.3e-9, "unit": "s"},
241
+ ... "frequency": {"value": 440.0, "unit": "Hz"}
242
+ ... }
243
+ >>> print(format_measurement_dict(measurements, html=False))
244
+ Rise Time: 2.300 ns
245
+ Frequency: 440.0 Hz
246
+ """
247
+ formatter = MeasurementFormatter(number_formatter=NumberFormatter(sig_figs=sig_figs))
248
+ return formatter.format_measurement_dict(measurements, html=html)
249
+
250
+
251
+ def convert_to_measurement_dict(
252
+ raw_measurements: dict[str, float], unit_map: dict[str, str]
253
+ ) -> dict[str, dict[str, Any]]:
254
+ """Convert raw measurement dictionary to structured measurement format.
255
+
256
+ Helper function to convert simple {name: value} dictionaries into
257
+ the full measurement format with units.
258
+
259
+ Args:
260
+ raw_measurements: Dictionary mapping parameter names to numeric values.
261
+ unit_map: Dictionary mapping parameter names to unit strings.
262
+
263
+ Returns:
264
+ Dictionary in measurement format with value and unit fields.
265
+
266
+ Example:
267
+ >>> raw = {"rise_time": 2.3e-9, "frequency": 440.0}
268
+ >>> units = {"rise_time": "s", "frequency": "Hz"}
269
+ >>> convert_to_measurement_dict(raw, units)
270
+ {
271
+ 'rise_time': {'value': 2.3e-09, 'unit': 's'},
272
+ 'frequency': {'value': 440.0, 'unit': 'Hz'}
273
+ }
274
+ """
275
+ return {
276
+ key: {"value": value, "unit": unit_map.get(key, "")}
277
+ for key, value in raw_measurements.items()
278
+ if isinstance(value, (int, float))
279
+ }
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,