scrollkit 0.8.3__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 (111) hide show
  1. scrollkit/__init__.py +16 -0
  2. scrollkit/app/__init__.py +6 -0
  3. scrollkit/app/base.py +918 -0
  4. scrollkit/app/memory.py +70 -0
  5. scrollkit/config/__init__.py +1 -0
  6. scrollkit/config/settings_manager.py +215 -0
  7. scrollkit/config/transition_names.py +31 -0
  8. scrollkit/dev/__init__.py +41 -0
  9. scrollkit/dev/capabilities.py +374 -0
  10. scrollkit/dev/harness.py +383 -0
  11. scrollkit/dev/metrics.py +91 -0
  12. scrollkit/dev/performance.py +174 -0
  13. scrollkit/dev/validation.py +245 -0
  14. scrollkit/display/__init__.py +14 -0
  15. scrollkit/display/_graphics.py +299 -0
  16. scrollkit/display/_recording.py +165 -0
  17. scrollkit/display/_sim_backend.py +99 -0
  18. scrollkit/display/bitmap_text.py +408 -0
  19. scrollkit/display/boards.py +186 -0
  20. scrollkit/display/colors.py +176 -0
  21. scrollkit/display/content.py +604 -0
  22. scrollkit/display/gradient_text.py +167 -0
  23. scrollkit/display/interface.py +126 -0
  24. scrollkit/display/simulator.py +80 -0
  25. scrollkit/display/text_fill.py +59 -0
  26. scrollkit/display/text_pixels.py +250 -0
  27. scrollkit/display/unified.py +595 -0
  28. scrollkit/effects/__init__.py +28 -0
  29. scrollkit/effects/drip_splash.py +253 -0
  30. scrollkit/effects/easing.py +131 -0
  31. scrollkit/effects/overlay.py +92 -0
  32. scrollkit/effects/particles.py +355 -0
  33. scrollkit/effects/reveal_splash.py +132 -0
  34. scrollkit/effects/scrolling.py +363 -0
  35. scrollkit/effects/swarm_reveal.py +512 -0
  36. scrollkit/effects/text_render.py +25 -0
  37. scrollkit/effects/transitions.py +873 -0
  38. scrollkit/exceptions.py +55 -0
  39. scrollkit/network/__init__.py +1 -0
  40. scrollkit/network/http_client.py +505 -0
  41. scrollkit/network/mdns.py +44 -0
  42. scrollkit/network/wifi_manager.py +382 -0
  43. scrollkit/ota/__init__.py +13 -0
  44. scrollkit/ota/client.py +528 -0
  45. scrollkit/ota/display_progress.py +125 -0
  46. scrollkit/ota/manifest.py +206 -0
  47. scrollkit/ota/publish.py +379 -0
  48. scrollkit/simulator/ATTRIBUTION.md +20 -0
  49. scrollkit/simulator/CIRCUITPYTHON_COMPATIBILITY.md +364 -0
  50. scrollkit/simulator/LICENSE +176 -0
  51. scrollkit/simulator/README.md +87 -0
  52. scrollkit/simulator/__init__.py +11 -0
  53. scrollkit/simulator/adafruit_bitmap_font/__init__.py +5 -0
  54. scrollkit/simulator/adafruit_bitmap_font/bitmap_font.py +273 -0
  55. scrollkit/simulator/adafruit_bitmap_font/glyph_cache.py +70 -0
  56. scrollkit/simulator/adafruit_display_text/__init__.py +5 -0
  57. scrollkit/simulator/adafruit_display_text/label.py +336 -0
  58. scrollkit/simulator/bitmaptools.py +50 -0
  59. scrollkit/simulator/core/__init__.py +8 -0
  60. scrollkit/simulator/core/color_utils.py +62 -0
  61. scrollkit/simulator/core/device_benchmarks.json +254 -0
  62. scrollkit/simulator/core/feasibility.py +160 -0
  63. scrollkit/simulator/core/hardware_profile.py +191 -0
  64. scrollkit/simulator/core/led_matrix.py +307 -0
  65. scrollkit/simulator/core/matrixportal_s3_baseline.json +12 -0
  66. scrollkit/simulator/core/performance_manager.py +253 -0
  67. scrollkit/simulator/core/pixel_buffer.py +188 -0
  68. scrollkit/simulator/devices/__init__.py +7 -0
  69. scrollkit/simulator/devices/base_device.py +79 -0
  70. scrollkit/simulator/devices/matrixportal_s3.py +87 -0
  71. scrollkit/simulator/displayio/__init__.py +12 -0
  72. scrollkit/simulator/displayio/bitmap.py +158 -0
  73. scrollkit/simulator/displayio/display.py +195 -0
  74. scrollkit/simulator/displayio/fourwire.py +54 -0
  75. scrollkit/simulator/displayio/group.py +125 -0
  76. scrollkit/simulator/displayio/ondiskbitmap.py +109 -0
  77. scrollkit/simulator/displayio/palette.py +115 -0
  78. scrollkit/simulator/displayio/tilegrid.py +155 -0
  79. scrollkit/simulator/fonts/3x5.bdf +2474 -0
  80. scrollkit/simulator/fonts/Arial_16.bdf +7366 -0
  81. scrollkit/simulator/fonts/Arial_16.bdf.license +3 -0
  82. scrollkit/simulator/fonts/Arial_Bold_12.bdf +6131 -0
  83. scrollkit/simulator/fonts/Arial_Bold_12.bdf.license +2 -0
  84. scrollkit/simulator/fonts/Arial_Bold_18.bdf +32653 -0
  85. scrollkit/simulator/fonts/Arial_Bold_18.bdf.license +2 -0
  86. scrollkit/simulator/fonts/Junction_regular_24.bdf +8676 -0
  87. scrollkit/simulator/fonts/Junction_regular_24.bdf.license +3 -0
  88. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf +12458 -0
  89. scrollkit/simulator/fonts/LeagueSpartan-Bold-16.bdf.license +1921 -0
  90. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf +12458 -0
  91. scrollkit/simulator/fonts/LeagueSpartan_Bold_16.bdf.license +4 -0
  92. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf +16818 -0
  93. scrollkit/simulator/fonts/LibreBodoniv2002-Bold-27.bdf.license +1921 -0
  94. scrollkit/simulator/fonts/tom-thumb.bdf +2353 -0
  95. scrollkit/simulator/fonts/viii-bold.bdf +2673 -0
  96. scrollkit/simulator/fonts/viii.bdf +2659 -0
  97. scrollkit/simulator/terminalio/__init__.py +20 -0
  98. scrollkit/utils/__init__.py +1 -0
  99. scrollkit/utils/color_utils.py +54 -0
  100. scrollkit/utils/diagnostics.py +227 -0
  101. scrollkit/utils/error_handler.py +347 -0
  102. scrollkit/utils/system_utils.py +245 -0
  103. scrollkit/utils/url_utils.py +46 -0
  104. scrollkit/web/__init__.py +6 -0
  105. scrollkit/web/settings_server.py +328 -0
  106. scrollkit/web/wifi_setup.py +331 -0
  107. scrollkit-0.8.3.dist-info/METADATA +248 -0
  108. scrollkit-0.8.3.dist-info/RECORD +111 -0
  109. scrollkit-0.8.3.dist-info/WHEEL +5 -0
  110. scrollkit-0.8.3.dist-info/licenses/LICENSE +31 -0
  111. scrollkit-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,50 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Desktop emulation of CircuitPython's ``bitmaptools`` C module.
3
+
4
+ ScrollKit's span/rect painters, overlay-mask, and bitmap-text all express bounded
5
+ work through ``bitmaptools.fill_region`` / ``bitmaptools.blit`` so the SAME code
6
+ runs on the device (where ``bitmaptools`` is a built-in) and on the desktop
7
+ simulator (where this shim provides it). The display resolves which module to use
8
+ via ``display.gfx``.
9
+
10
+ Semantics mirror the device for the subset ScrollKit uses:
11
+ - ``fill_region`` fills the HALF-OPEN rectangle ``[x1, x2) x [y1, y2)`` clipped to
12
+ the bitmap; an empty/inverted region is a no-op.
13
+ - ``blit`` copies a source sub-rectangle into the destination at ``(x, y)`` with
14
+ ``skip_index`` transparency, clipped at all four edges (incl. negative offsets).
15
+
16
+ Desktop-only — never imported on CircuitPython (the device uses the real module).
17
+ A golden corpus captured on a real board pins this shim's fidelity
18
+ (``test/unit/display/test_bitmaptools_shim.py``); if it ever diverges, fix the
19
+ shim, never the shared effect logic.
20
+ """
21
+
22
+
23
+ def fill_region(bitmap, x1, y1, x2, y2, value):
24
+ """Fill the half-open rectangle ``[x1, x2) x [y1, y2)`` with ``value``.
25
+
26
+ Clipped to the bitmap. Matching CircuitPython, an inverted or empty region
27
+ (``x1 >= x2`` or ``y1 >= y2``) is a no-op — the bounds are NOT reordered, so
28
+ the simulator and device agree.
29
+ """
30
+ if x1 >= x2 or y1 >= y2:
31
+ return
32
+ w = bitmap.width
33
+ h = bitmap.height
34
+ xa = x1 if x1 > 0 else 0
35
+ ya = y1 if y1 > 0 else 0
36
+ xb = x2 if x2 < w else w
37
+ yb = y2 if y2 < h else h
38
+ if xb <= xa or yb <= ya:
39
+ return
40
+ bitmap._buffer[ya:yb, xa:xb] = value
41
+
42
+
43
+ def blit(dest, source, x, y, *, x1=0, y1=0, x2=None, y2=None, skip_index=None):
44
+ """Copy ``source`` (sub-rect ``[x1,x2) x [y1,y2)``) into ``dest`` at ``(x, y)``.
45
+
46
+ Honors ``skip_index`` transparency and clips at every edge, including negative
47
+ destination offsets. Delegates to the simulator ``Bitmap.blit``, which already
48
+ implements the CircuitPython clipping/transparency rules.
49
+ """
50
+ dest.blit(x, y, source, x1=x1, y1=y1, x2=x2, y2=y2, skip_index=skip_index)
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Core simulator components."""
3
+
4
+ from .led_matrix import LEDMatrix
5
+ from .pixel_buffer import PixelBuffer
6
+ from .color_utils import *
7
+
8
+ __all__ = ['LEDMatrix', 'PixelBuffer']
@@ -0,0 +1,62 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Color manipulation utilities for LED simulation."""
3
+
4
+ def rgb565_to_rgb888(color565):
5
+ """Convert RGB565 color to RGB888 (standard 24-bit RGB).
6
+
7
+ Args:
8
+ color565: 16-bit color value in RGB565 format
9
+
10
+ Returns:
11
+ Tuple of (r, g, b) values in 0-255 range
12
+ """
13
+ r = ((color565 >> 11) & 0x1F) << 3
14
+ g = ((color565 >> 5) & 0x3F) << 2
15
+ b = (color565 & 0x1F) << 3
16
+
17
+ # Add the MSB bits to the LSB for better color accuracy
18
+ r |= r >> 5
19
+ g |= g >> 6
20
+ b |= b >> 5
21
+
22
+ return (r, g, b)
23
+
24
+
25
+ def rgb888_to_rgb565(r, g, b):
26
+ """Convert RGB888 color to RGB565.
27
+
28
+ Args:
29
+ r: Red component (0-255)
30
+ g: Green component (0-255)
31
+ b: Blue component (0-255)
32
+
33
+ Returns:
34
+ 16-bit color value in RGB565 format
35
+ """
36
+ return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
37
+
38
+
39
+ def apply_brightness(color, brightness):
40
+ """Apply brightness adjustment to RGB color.
41
+
42
+ Args:
43
+ color: Tuple of (r, g, b) values
44
+ brightness: Float from 0.0 to 1.0
45
+
46
+ Returns:
47
+ Tuple of brightness-adjusted (r, g, b) values
48
+ """
49
+ return tuple(int(c * brightness) for c in color)
50
+
51
+
52
+ # Common color constants matching CircuitPython
53
+ BLACK = 0x0000
54
+ WHITE = 0xFFFF
55
+ RED = 0xF800
56
+ GREEN = 0x07E0
57
+ BLUE = 0x001F
58
+ CYAN = 0x07FF
59
+ MAGENTA = 0xF81F
60
+ YELLOW = 0xFFE0
61
+ ORANGE = 0xFD20
62
+ PURPLE = 0x8010
@@ -0,0 +1,254 @@
1
+ {
2
+ "board": "adafruit_matrixportal_s3",
3
+ "cp": "9.1.0",
4
+ "benchmarks": [
5
+ {
6
+ "name": "noop_loop_iter",
7
+ "category": "compute",
8
+ "value": 2018.74,
9
+ "note": "for-loop overhead (subtracted below)",
10
+ "iters": 20000,
11
+ "unit": "ns/op"
12
+ },
13
+ {
14
+ "name": "int_add",
15
+ "category": "compute",
16
+ "value": 1538.09,
17
+ "note": "",
18
+ "iters": 20000,
19
+ "unit": "ns/op"
20
+ },
21
+ {
22
+ "name": "int_mul",
23
+ "category": "compute",
24
+ "value": 1684.57,
25
+ "note": "",
26
+ "iters": 20000,
27
+ "unit": "ns/op"
28
+ },
29
+ {
30
+ "name": "float_add",
31
+ "category": "compute",
32
+ "value": 2069.09,
33
+ "note": "",
34
+ "iters": 20000,
35
+ "unit": "ns/op"
36
+ },
37
+ {
38
+ "name": "float_mul",
39
+ "category": "compute",
40
+ "value": 2066.04,
41
+ "note": "",
42
+ "iters": 20000,
43
+ "unit": "ns/op"
44
+ },
45
+ {
46
+ "name": "list_index",
47
+ "category": "compute",
48
+ "value": 2917.48,
49
+ "note": "",
50
+ "iters": 20000,
51
+ "unit": "ns/op"
52
+ },
53
+ {
54
+ "name": "dict_get",
55
+ "category": "compute",
56
+ "value": 2337.65,
57
+ "note": "",
58
+ "iters": 20000,
59
+ "unit": "ns/op"
60
+ },
61
+ {
62
+ "name": "func_call",
63
+ "category": "compute",
64
+ "value": 4661.56,
65
+ "note": "",
66
+ "iters": 20000,
67
+ "unit": "ns/op"
68
+ },
69
+ {
70
+ "name": "attr_access",
71
+ "category": "compute",
72
+ "value": 2259.83,
73
+ "note": "",
74
+ "iters": 20000,
75
+ "unit": "ns/op"
76
+ },
77
+ {
78
+ "name": "bitmap_setpixel",
79
+ "category": "pixel_interpreted",
80
+ "value": 6996.15,
81
+ "note": "bmp[x,y]=1",
82
+ "iters": 10000,
83
+ "unit": "ns/op"
84
+ },
85
+ {
86
+ "name": "bitmap_getpixel",
87
+ "category": "pixel_interpreted",
88
+ "value": 6690.98,
89
+ "note": "",
90
+ "iters": 10000,
91
+ "unit": "ns/op"
92
+ },
93
+ {
94
+ "name": "bitmap_fill_64x32",
95
+ "category": "bulk_c",
96
+ "value": 8926.39,
97
+ "note": "C fill 2048 px",
98
+ "iters": 4000,
99
+ "unit": "ns/call"
100
+ },
101
+ {
102
+ "name": "bitmaptools_blit_16x16",
103
+ "category": "bulk_c",
104
+ "value": 159462.0,
105
+ "note": "C blit 256 px",
106
+ "iters": 4000,
107
+ "unit": "ns/call"
108
+ },
109
+ {
110
+ "name": "bitmaptools_fill_region",
111
+ "category": "bulk_c",
112
+ "value": 147049.0,
113
+ "note": "512 px",
114
+ "iters": 4000,
115
+ "unit": "ns/call"
116
+ },
117
+ {
118
+ "name": "bitmaptools_draw_line",
119
+ "category": "bulk_c",
120
+ "value": 29617.3,
121
+ "note": "",
122
+ "iters": 4000,
123
+ "unit": "ns/call"
124
+ },
125
+ {
126
+ "name": "palette_set",
127
+ "category": "display_obj",
128
+ "value": 2629.09,
129
+ "note": "",
130
+ "iters": 10000,
131
+ "unit": "ns/op"
132
+ },
133
+ {
134
+ "name": "tilegrid_create",
135
+ "category": "display_obj",
136
+ "value": 47607.4,
137
+ "note": "alloc+init",
138
+ "iters": 500,
139
+ "unit": "ns/call"
140
+ },
141
+ {
142
+ "name": "tilegrid_move_x",
143
+ "category": "display_obj",
144
+ "value": 2491.76,
145
+ "note": "",
146
+ "iters": 10000,
147
+ "unit": "ns/op"
148
+ },
149
+ {
150
+ "name": "group_append_pop_pair",
151
+ "category": "display_obj",
152
+ "value": 10721.8,
153
+ "note": "",
154
+ "iters": 3000,
155
+ "unit": "ns/pair"
156
+ },
157
+ {
158
+ "name": "bitmap_alloc_32x16",
159
+ "category": "memory",
160
+ "value": 123291.0,
161
+ "note": "",
162
+ "iters": 1000,
163
+ "unit": "ns/alloc"
164
+ },
165
+ {
166
+ "name": "bytearray_alloc_256",
167
+ "category": "memory",
168
+ "value": 73989.8,
169
+ "note": "",
170
+ "iters": 2000,
171
+ "unit": "ns/alloc"
172
+ },
173
+ {
174
+ "name": "gc_collect",
175
+ "category": "memory",
176
+ "value": 899251.0,
177
+ "note": "clean heap",
178
+ "iters": 30,
179
+ "unit": "ns/call"
180
+ },
181
+ {
182
+ "name": "monotonic_ns_call",
183
+ "category": "io",
184
+ "value": 8848.57,
185
+ "note": "",
186
+ "iters": 10000,
187
+ "unit": "ns/op"
188
+ },
189
+ {
190
+ "name": "refresh_empty_bd1",
191
+ "category": "refresh",
192
+ "value": 14241.3,
193
+ "note": "bit_depth=1, empty group",
194
+ "iters": 30,
195
+ "unit": "ns/call"
196
+ },
197
+ {
198
+ "name": "refresh_full_bd1",
199
+ "category": "refresh",
200
+ "value": 4312130.0,
201
+ "note": "bit_depth=1, 64x32 recomposite",
202
+ "iters": 30,
203
+ "unit": "ns/call"
204
+ },
205
+ {
206
+ "name": "refresh_empty_bd2",
207
+ "category": "refresh",
208
+ "value": 15259.0,
209
+ "note": "bit_depth=2, empty group",
210
+ "iters": 30,
211
+ "unit": "ns/call"
212
+ },
213
+ {
214
+ "name": "refresh_full_bd2",
215
+ "category": "refresh",
216
+ "value": 4372150.0,
217
+ "note": "bit_depth=2, 64x32 recomposite",
218
+ "iters": 30,
219
+ "unit": "ns/call"
220
+ },
221
+ {
222
+ "name": "refresh_empty_bd4",
223
+ "category": "refresh",
224
+ "value": 17293.1,
225
+ "note": "bit_depth=4, empty group",
226
+ "iters": 30,
227
+ "unit": "ns/call"
228
+ },
229
+ {
230
+ "name": "refresh_full_bd4",
231
+ "category": "refresh",
232
+ "value": 4492190.0,
233
+ "note": "bit_depth=4, 64x32 recomposite",
234
+ "iters": 30,
235
+ "unit": "ns/call"
236
+ },
237
+ {
238
+ "name": "refresh_empty_bd6",
239
+ "category": "refresh",
240
+ "value": 14241.3,
241
+ "note": "bit_depth=6, empty group",
242
+ "iters": 30,
243
+ "unit": "ns/call"
244
+ },
245
+ {
246
+ "name": "refresh_full_bd6",
247
+ "category": "refresh",
248
+ "value": 13691200.0,
249
+ "note": "bit_depth=6, 64x32 recomposite",
250
+ "iters": 30,
251
+ "unit": "ns/call"
252
+ }
253
+ ]
254
+ }
@@ -0,0 +1,160 @@
1
+ # Copyright (c) 2024-2026 Michael Winslow Czeiszperger
2
+ """Turn accumulated modeled frame costs into a human/AI-readable feasibility report.
3
+
4
+ Answers: "would this app actually run on the real hardware?" — estimated hardware
5
+ FPS, where the per-frame time goes, estimated peak RAM vs budget, and actionable
6
+ warnings. Always labels itself as an estimate when the profile is uncalibrated.
7
+
8
+ Desktop/simulator only.
9
+ """
10
+
11
+ import math
12
+
13
+
14
+ def _one_sig_fig(x):
15
+ """Round to one significant figure (so '~5 FPS', not '5.37 FPS')."""
16
+ if x <= 0:
17
+ return 0
18
+ d = math.floor(math.log10(x))
19
+ factor = 10 ** d
20
+ return round(x / factor) * factor
21
+
22
+
23
+ def _median(values):
24
+ s = sorted(values)
25
+ n = len(s)
26
+ if n == 0:
27
+ return 0.0
28
+ mid = n // 2
29
+ return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2.0
30
+
31
+
32
+ class FeasibilityReport:
33
+ """Estimated hardware performance for a run, built from a PerformanceManager."""
34
+
35
+ def __init__(self, profile_name, confidence, source, calibrated,
36
+ est_hw_fps, median_frame_ms, worst_frame_ms, breakdown_ms,
37
+ est_peak_ram_bytes, ram_budget_bytes, warnings):
38
+ self.profile_name = profile_name
39
+ self.confidence = confidence
40
+ self.source = source
41
+ self.calibrated = calibrated
42
+ self.est_hw_fps = est_hw_fps # None if no frames were rendered
43
+ self.median_frame_ms = median_frame_ms
44
+ self.worst_frame_ms = worst_frame_ms
45
+ self.breakdown_ms = breakdown_ms # {category: ms} averaged per frame
46
+ self.est_peak_ram_bytes = est_peak_ram_bytes
47
+ self.ram_budget_bytes = ram_budget_bytes
48
+ self.warnings = warnings
49
+
50
+ # ------------------------------------------------------------------
51
+ @classmethod
52
+ def from_manager(cls, manager):
53
+ profile = manager.profile
54
+ frames = manager.frames
55
+ n = len(frames)
56
+
57
+ if n == 0:
58
+ return cls(profile.name, profile.confidence, profile.source,
59
+ profile.is_calibrated, None, 0.0, 0.0, {},
60
+ manager.estimated_peak_ram_bytes(), profile.usable_ram_bytes,
61
+ ["No frames rendered yet — run the app (and call show()) first."])
62
+
63
+ totals_us = [f.total_us for f in frames]
64
+ median_us = _median(totals_us)
65
+ worst_us = max(totals_us)
66
+ est_fps = (1_000_000.0 / median_us) if median_us > 0 else None
67
+
68
+ # Average per-category breakdown (ms), and which category dominates.
69
+ cats = ("bitmap_rebuild_us", "refresh_us", "pixel_writes_us", "gc_us",
70
+ "bulk_ops_us")
71
+ breakdown_ms = {}
72
+ for cat in cats:
73
+ avg_us = sum(getattr(f, cat) for f in frames) / n
74
+ breakdown_ms[cat.replace("_us", "")] = avg_us / 1000.0
75
+ dominant = max(breakdown_ms, key=lambda k: breakdown_ms[k])
76
+
77
+ peak_ram = manager.estimated_peak_ram_bytes()
78
+ budget = profile.usable_ram_bytes
79
+
80
+ warnings = []
81
+ if est_fps is not None and est_fps < 1:
82
+ warnings.append(
83
+ "Frame would take ~%d ms on hardware (~%s FPS) — effectively frozen."
84
+ % (round(median_us / 1000.0), _fps_text(est_fps, profile.is_calibrated)))
85
+ elif est_fps is not None and est_fps < 10:
86
+ warnings.append(
87
+ "Frame would take ~%d ms on hardware (~%s FPS%s). Scrolling will stutter."
88
+ % (round(median_us / 1000.0), _fps_text(est_fps, profile.is_calibrated),
89
+ "" if profile.is_calibrated else ", ESTIMATE"))
90
+ if dominant == "bitmap_rebuild" and breakdown_ms["bitmap_rebuild"] > 0 \
91
+ and breakdown_ms["bitmap_rebuild"] >= 0.5 * (median_us / 1000.0):
92
+ warnings.append(
93
+ "draw_text rebuilds a glyph bitmap every frame (the dominant cost on "
94
+ "hardware). Cache the Label/content and only change .text when it "
95
+ "actually changes, instead of redrawing every frame.")
96
+ if peak_ram > budget:
97
+ warnings.append(
98
+ "Estimated peak RAM ~%d KB exceeds the modeled device budget of %d KB "
99
+ "— won't fit. Reduce content/effects or disable the web server."
100
+ % (peak_ram // 1024, budget // 1024))
101
+ elif peak_ram > 0.8 * budget:
102
+ warnings.append(
103
+ "Estimated peak RAM ~%d KB is close to the %d KB budget — little "
104
+ "headroom on hardware." % (peak_ram // 1024, budget // 1024))
105
+
106
+ return cls(profile.name, profile.confidence, profile.source,
107
+ profile.is_calibrated, est_fps, median_us / 1000.0,
108
+ worst_us / 1000.0, breakdown_ms, peak_ram, budget, warnings)
109
+
110
+ # ------------------------------------------------------------------
111
+ def as_dict(self):
112
+ return {
113
+ "profile": self.profile_name,
114
+ "confidence": self.confidence,
115
+ "calibrated": self.calibrated,
116
+ "source": self.source,
117
+ "estimated_hardware_fps": self.est_hw_fps,
118
+ "median_frame_ms": round(self.median_frame_ms, 2),
119
+ "worst_frame_ms": round(self.worst_frame_ms, 2),
120
+ "breakdown_ms": {k: round(v, 2) for k, v in self.breakdown_ms.items()},
121
+ "estimated_peak_ram_bytes": self.est_peak_ram_bytes,
122
+ "ram_budget_bytes": self.ram_budget_bytes,
123
+ "warnings": list(self.warnings),
124
+ }
125
+
126
+ def as_text(self):
127
+ lines = ["=== Hardware feasibility: %s ===" % self.profile_name]
128
+ if self.calibrated:
129
+ lines.append(" Confidence: MEASURED on device (%s)" % self.source)
130
+ else:
131
+ lines.append(" Confidence: ROUGH ESTIMATE, not measured on device (%s)"
132
+ % self.source)
133
+ if self.est_hw_fps is None:
134
+ lines.append(" Estimated hardware FPS: n/a (no frames rendered)")
135
+ else:
136
+ lines.append(" Estimated hardware FPS: ~%s (median frame ~%d ms, worst ~%d ms)"
137
+ % (_fps_text(self.est_hw_fps, self.calibrated),
138
+ round(self.median_frame_ms), round(self.worst_frame_ms)))
139
+ if self.breakdown_ms:
140
+ parts = sorted(self.breakdown_ms.items(), key=lambda kv: -kv[1])
141
+ lines.append(" Per-frame cost (avg): "
142
+ + " | ".join("%s %.1f ms" % (k, v) for k, v in parts))
143
+ lines.append(" Estimated peak RAM: %d KB / %d KB budget"
144
+ % (self.est_peak_ram_bytes // 1024, self.ram_budget_bytes // 1024))
145
+ if self.warnings:
146
+ lines.append(" Warnings:")
147
+ for w in self.warnings:
148
+ lines.append(" - " + w)
149
+ else:
150
+ lines.append(" No feasibility warnings.")
151
+ return "\n".join(lines)
152
+
153
+
154
+ def _fps_text(fps, calibrated):
155
+ if fps is None:
156
+ return "n/a"
157
+ if calibrated:
158
+ return "%.1f" % fps
159
+ sig = _one_sig_fig(fps)
160
+ return ("%g" % sig)