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.
- oscura/__init__.py +1 -1
- oscura/analyzers/eye/__init__.py +5 -1
- oscura/analyzers/eye/generation.py +501 -0
- oscura/analyzers/jitter/__init__.py +6 -6
- oscura/analyzers/jitter/timing.py +419 -0
- oscura/analyzers/patterns/__init__.py +28 -0
- oscura/analyzers/patterns/reverse_engineering.py +991 -0
- oscura/analyzers/power/__init__.py +35 -12
- oscura/analyzers/statistics/__init__.py +4 -0
- oscura/analyzers/statistics/basic.py +149 -0
- oscura/analyzers/statistics/correlation.py +47 -6
- oscura/analyzers/waveform/__init__.py +2 -0
- oscura/analyzers/waveform/measurements.py +145 -23
- oscura/analyzers/waveform/spectral.py +361 -8
- oscura/automotive/__init__.py +1 -1
- oscura/core/config/loader.py +0 -1
- oscura/core/types.py +108 -0
- oscura/loaders/__init__.py +12 -4
- oscura/loaders/tss.py +456 -0
- oscura/reporting/__init__.py +88 -1
- oscura/reporting/automation.py +348 -0
- oscura/reporting/citations.py +374 -0
- oscura/reporting/core.py +54 -0
- oscura/reporting/formatting/__init__.py +11 -0
- oscura/reporting/formatting/measurements.py +279 -0
- oscura/reporting/html.py +57 -0
- oscura/reporting/interpretation.py +431 -0
- oscura/reporting/summary.py +329 -0
- oscura/reporting/visualization.py +542 -0
- oscura/visualization/__init__.py +2 -1
- oscura/visualization/batch.py +521 -0
- oscura/workflows/__init__.py +2 -0
- oscura/workflows/waveform.py +783 -0
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/METADATA +37 -19
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/RECORD +38 -26
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/WHEEL +0 -0
- {oscura-0.6.0.dist-info → oscura-0.8.0.dist-info}/entry_points.txt +0 -0
- {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,
|