quantized-lab 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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,153 @@
1
+ """Thin-film calculator routes. Wraps ``calc.thin_film`` (pure formulas):
2
+ deposition / sputter rate, diffusion length, implant dose + peak concentration,
3
+ Kiessig thickness, multilayer thermal conductivity, projected range, Stoney
4
+ stress, thermal-mismatch strain. Validate -> call the pure fn -> serialize.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException
13
+ from pydantic import BaseModel
14
+
15
+ from quantized.calc import thin_film
16
+
17
+ router = APIRouter(prefix="/api/thin-film", tags=["thin-film"])
18
+
19
+
20
+ class DepositionRateRequest(BaseModel):
21
+ thickness: float # Å
22
+ time: float # s
23
+
24
+
25
+ class DiffusionLengthRequest(BaseModel):
26
+ d: float # cm^2/s
27
+ t: float # s
28
+
29
+
30
+ class DoseFromCurrentRequest(BaseModel):
31
+ current: float # A
32
+ time: float # s
33
+ area: float # cm^2
34
+
35
+
36
+ class DoseToConcentrationRequest(BaseModel):
37
+ dose: float # ions/cm^2
38
+ rp: float # nm
39
+ delta_rp: float # nm
40
+
41
+
42
+ class KiessigRequest(BaseModel):
43
+ delta_q: float # Å^-1
44
+ sld: float | None = None # Å^-2
45
+ qc: float | None = None # Å^-1
46
+
47
+
48
+ class MultilayerThermalRequest(BaseModel):
49
+ thicknesses: list[float] # nm
50
+ kappas: list[float] # W/m/K
51
+
52
+
53
+ class ProjectedRangeRequest(BaseModel):
54
+ ion: str
55
+ target: str
56
+ energy: float # keV
57
+
58
+
59
+ class SputterRateRequest(BaseModel):
60
+ y: float # atoms/ion
61
+ j: float # mA/cm^2
62
+ rho: float # g/cm^3
63
+ m: float # g/mol
64
+
65
+
66
+ class StoneyStressRequest(BaseModel):
67
+ es: float # Pa
68
+ nus: float
69
+ ts: float # m
70
+ tf: float # m
71
+ r: float # m
72
+
73
+
74
+ class ThermalMismatchRequest(BaseModel):
75
+ alpha_film: float # 1/K
76
+ alpha_sub: float # 1/K
77
+ delta_t: float # K
78
+ e: float | None = None # Pa
79
+ nu: float = 0.3
80
+
81
+
82
+ def _call(fn: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any) -> dict[str, Any]:
83
+ try:
84
+ return fn(*args, **kwargs)
85
+ except ValueError as exc:
86
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
87
+
88
+
89
+ @router.post("/deposition-rate")
90
+ def deposition_rate(req: DepositionRateRequest) -> dict[str, Any]:
91
+ """r = thickness/time (Å/s, nm/min)."""
92
+ return _call(thin_film.deposition_rate, req.thickness, req.time)
93
+
94
+
95
+ @router.post("/diffusion-length")
96
+ def diffusion_length(req: DiffusionLengthRequest) -> dict[str, Any]:
97
+ """L = √(D·t) (cm/nm/µm)."""
98
+ return _call(thin_film.diffusion_length_thermal, req.d, req.t)
99
+
100
+
101
+ @router.post("/dose-from-current")
102
+ def dose_from_current(req: DoseFromCurrentRequest) -> dict[str, Any]:
103
+ """Φ = I·t/(q·A) (ions/cm²)."""
104
+ return _call(thin_film.dose_from_current, req.current, req.time, req.area)
105
+
106
+
107
+ @router.post("/dose-to-concentration")
108
+ def dose_to_concentration(req: DoseToConcentrationRequest) -> dict[str, Any]:
109
+ """C_peak = dose/(√(2π)·ΔRp) (atoms/cm³)."""
110
+ return _call(thin_film.dose_to_concentration, req.dose, req.rp, req.delta_rp)
111
+
112
+
113
+ @router.post("/kiessig-thickness")
114
+ def kiessig_thickness(req: KiessigRequest) -> dict[str, Any]:
115
+ """t = 2π/ΔQ (refraction-corrected when SLD/Qc supplied)."""
116
+ return _call(thin_film.kiessig_thickness, req.delta_q, sld=req.sld, qc=req.qc)
117
+
118
+
119
+ @router.post("/multilayer-thermal")
120
+ def multilayer_thermal(req: MultilayerThermalRequest) -> dict[str, Any]:
121
+ """Series / parallel effective thermal conductivity (W/m/K)."""
122
+ return _call(thin_film.multilayer_thermal_conductivity, req.thicknesses, req.kappas)
123
+
124
+
125
+ @router.post("/projected-range")
126
+ def projected_range(req: ProjectedRangeRequest) -> dict[str, Any]:
127
+ """LSS projected range Rp + straggle ΔRp (nm)."""
128
+ return _call(thin_film.projected_range, req.ion, req.target, req.energy)
129
+
130
+
131
+ @router.post("/sputter-rate")
132
+ def sputter_rate(req: SputterRateRequest) -> dict[str, Any]:
133
+ """Sputter erosion rate (nm/s, nm/min)."""
134
+ return _call(thin_film.sputter_rate, req.y, req.j, req.rho, req.m)
135
+
136
+
137
+ @router.post("/stoney-stress")
138
+ def stoney_stress(req: StoneyStressRequest) -> dict[str, Any]:
139
+ """σ = E_s·t_s²/(6(1−ν_s)·t_f·R) (Pa)."""
140
+ return _call(thin_film.stoney_stress, req.es, req.nus, req.ts, req.tf, req.r)
141
+
142
+
143
+ @router.post("/thermal-mismatch")
144
+ def thermal_mismatch(req: ThermalMismatchRequest) -> dict[str, Any]:
145
+ """ε = (α_f−α_s)·ΔT, σ = E·ε/(1−ν) (MPa)."""
146
+ return _call(
147
+ thin_film.thermal_mismatch_strain,
148
+ req.alpha_film,
149
+ req.alpha_sub,
150
+ req.delta_t,
151
+ e=req.e,
152
+ nu=req.nu,
153
+ )
@@ -0,0 +1,113 @@
1
+ """Thin vacuum-science routes. Wraps ``calc.vacuum`` (pure formulas): mean free
2
+ path / monolayer time / sputter yield / pump-down time / Knudsen number / gas
3
+ flow conductance. Validate -> call the pure fn -> serialize.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+ from pydantic import BaseModel
13
+
14
+ from quantized.calc import vacuum
15
+
16
+ router = APIRouter(prefix="/api/vacuum", tags=["vacuum"])
17
+
18
+
19
+ class MeanFreePathRequest(BaseModel):
20
+ p: float
21
+ temperature: float = 300.0
22
+ d: float = 3.64e-10
23
+
24
+
25
+ class MonolayerTimeRequest(BaseModel):
26
+ p: float
27
+ m: float = 4.65e-26
28
+ temperature: float = 300.0
29
+ a_site: float = 1e-19
30
+
31
+
32
+ class KnudsenRequest(BaseModel):
33
+ mfp: float
34
+ length: float
35
+
36
+
37
+ class PumpDownRequest(BaseModel):
38
+ v: float
39
+ s: float
40
+ p0: float
41
+ pf: float
42
+
43
+
44
+ class SputterYieldRequest(BaseModel):
45
+ material: str
46
+ energy: float
47
+ ion: str = "Ar"
48
+
49
+
50
+ class GasFlowRequest(BaseModel):
51
+ p1: float
52
+ p2: float
53
+ d: float
54
+ length: float
55
+ temperature: float = 300.0
56
+ m: float = 4.65e-26
57
+
58
+
59
+ def _call(fn: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any) -> dict[str, Any]:
60
+ try:
61
+ return fn(*args, **kwargs)
62
+ except ValueError as exc:
63
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
64
+
65
+
66
+ @router.post("/mean-free-path")
67
+ def mean_free_path(req: MeanFreePathRequest) -> dict[str, Any]:
68
+ """λ = k_B·T / (√2·π·d²·P) (m)."""
69
+ return _call(vacuum.mean_free_path, req.p, temperature=req.temperature, d=req.d)
70
+
71
+
72
+ @router.post("/monolayer-time")
73
+ def monolayer_time(req: MonolayerTimeRequest) -> dict[str, Any]:
74
+ """t_mono = 1/(J·A_site) (s)."""
75
+ return _call(
76
+ vacuum.monolayer_time,
77
+ req.p,
78
+ m=req.m,
79
+ temperature=req.temperature,
80
+ a_site=req.a_site,
81
+ )
82
+
83
+
84
+ @router.post("/knudsen")
85
+ def knudsen(req: KnudsenRequest) -> dict[str, Any]:
86
+ """Kn = λ/L plus flow regime."""
87
+ return _call(vacuum.knudsen_number, req.mfp, req.length)
88
+
89
+
90
+ @router.post("/pump-down")
91
+ def pump_down(req: PumpDownRequest) -> dict[str, Any]:
92
+ """t = (V/S)·ln(P0/Pf) (s)."""
93
+ return _call(vacuum.pump_down_time, req.v, req.s, req.p0, req.pf)
94
+
95
+
96
+ @router.post("/sputter-yield")
97
+ def sputter_yield(req: SputterYieldRequest) -> dict[str, Any]:
98
+ """Y (atoms/ion) from the Ar-ion lookup table; NaN outside range."""
99
+ return _call(vacuum.sputter_yield, req.material, req.energy, ion=req.ion)
100
+
101
+
102
+ @router.post("/gas-flow")
103
+ def gas_flow(req: GasFlowRequest) -> dict[str, Any]:
104
+ """Molecular & viscous gas-flow conductance through a tube (L/s)."""
105
+ return _call(
106
+ vacuum.gas_flow,
107
+ req.p1,
108
+ req.p2,
109
+ req.d,
110
+ req.length,
111
+ temperature=req.temperature,
112
+ m=req.m,
113
+ )
@@ -0,0 +1,32 @@
1
+ """Thin X-ray / neutron calculator route. Wraps ``calc.xray`` (pure formulas).
2
+
3
+ One endpoint dispatches the Bragg / Q↔2θ scalar conversions by ``mode``; the
4
+ math lives in calc, this only validates + serializes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+ from pydantic import BaseModel
13
+
14
+ from quantized.calc.xray import xray_calc
15
+
16
+ router = APIRouter(prefix="/api/xray", tags=["xray"])
17
+
18
+
19
+ class XrayCalcRequest(BaseModel):
20
+ mode: str
21
+ wavelength: float
22
+ value: float
23
+ n: int = 1
24
+
25
+
26
+ @router.post("/calc")
27
+ def calc(req: XrayCalcRequest) -> dict[str, Any]:
28
+ """Bragg / Q↔2θ conversion. ``mode`` selects the quantity (see calc.xray)."""
29
+ try:
30
+ return xray_calc(req.mode, req.wavelength, req.value, req.n)
31
+ except ValueError as exc:
32
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -0,0 +1,42 @@
1
+ Field (Oe),Moment (emu)
2
+ -5000.0,-4.0000
3
+ -4750.0,-3.9500
4
+ -4500.0,-3.9000
5
+ -4250.0,-3.8500
6
+ -4000.0,-3.8000
7
+ -3750.0,-3.7500
8
+ -3500.0,-3.7000
9
+ -3250.0,-3.6500
10
+ -3000.0,-3.6000
11
+ -2750.0,-3.5500
12
+ -2500.0,-3.5000
13
+ -2250.0,-3.4500
14
+ -2000.0,-3.4000
15
+ -1750.0,-3.3500
16
+ -1500.0,-3.2999
17
+ -1250.0,-3.2498
18
+ -1000.0,-3.1993
19
+ -750.0,-3.1474
20
+ -500.0,-3.0910
21
+ -250.0,-3.0187
22
+ 0.0,-2.8921
23
+ 250.0,-2.5895
24
+ 500.0,-1.8054
25
+ 750.0,-0.2231
26
+ 1000.0,1.5864
27
+ 1250.0,2.6779
28
+ 1500.0,3.1241
29
+ 1750.0,3.2985
30
+ 2000.0,3.3852
31
+ 2250.0,3.4457
32
+ 2500.0,3.4988
33
+ 2750.0,3.5497
34
+ 3000.0,3.5999
35
+ 3250.0,3.6500
36
+ 3500.0,3.7000
37
+ 3750.0,3.7500
38
+ 4000.0,3.8000
39
+ 4250.0,3.8500
40
+ 4500.0,3.9000
41
+ 4750.0,3.9500
42
+ 5000.0,4.0000
@@ -0,0 +1,251 @@
1
+ """Desktop (``--desktop``, pywebview) and dev (``--dev``, Vite HMR) launch paths.
2
+
3
+ Adapted from fermiviewer ``server_launch.py`` + ``netprobe.py`` (shared
4
+ platform code — keep in sync). Split out of ``cli.py`` to respect the
5
+ 500-line god-module ceiling; ``main()``/arg parsing stay in cli.py and only
6
+ its ``--dev`` / ``--desktop`` branches (plus the health-polled browser open
7
+ on the default path) reach into this module.
8
+
9
+ The probe helpers (``_health_ok``/``_bind``) are pure stdlib so importing
10
+ this module stays cheap — uvicorn/pywebview are imported inside the launch
11
+ functions that need them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ import socket as _socket
23
+
24
+ __all__ = ["_open_when_healthy", "_resolve_port", "_run_desktop", "_run_dev"]
25
+
26
+ # Built SPA — same resolution as quantized.app._WEB_DIR / cli._WEB_DIR.
27
+ _WEB_DIR = Path(__file__).parent / "web"
28
+
29
+
30
+ def _frontend_dir() -> Path:
31
+ """The repo-checkout ``frontend/`` (``--dev`` needs sources, not a build)."""
32
+ return Path(__file__).resolve().parents[2] / "frontend"
33
+
34
+
35
+ def _health_ok(host: str, port: int, timeout: float = 0.4) -> bool:
36
+ """True iff a *quantized* server answers /api/health with ``status: ok`` —
37
+ tells our own instance apart from a foreign app on the port, and gates
38
+ the browser/window open on the server actually being up."""
39
+ import json
40
+ import urllib.request
41
+
42
+ try:
43
+ with urllib.request.urlopen(
44
+ f"http://{host}:{port}/api/health", timeout=timeout
45
+ ) as r:
46
+ if r.status != 200:
47
+ return False
48
+ data = json.loads(r.read())
49
+ return bool(isinstance(data, dict) and data.get("status") == "ok")
50
+ except Exception:
51
+ return False
52
+
53
+
54
+ def _bind(host: str, port: int) -> _socket.socket | None:
55
+ """Bind + listen on host:port, returning the live socket, or None if the
56
+ port is taken. Binding up front turns a busy port into a value we can
57
+ branch on (reuse a healthy sibling, refuse a foreign app) instead of an
58
+ 'address already in use' traceback; the socket is handed to
59
+ ``Server.run(sockets=[...])``, closing the check->bind race."""
60
+ import os
61
+ import socket
62
+
63
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
64
+ try:
65
+ # SO_REUSEADDR everywhere EXCEPT Windows, where it acts like
66
+ # SO_REUSEPORT and would let us bind a port a foreign server owns.
67
+ if os.name != "nt":
68
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
69
+ s.bind((host, port))
70
+ s.listen()
71
+ return s
72
+ except OSError:
73
+ s.close()
74
+ return None
75
+
76
+
77
+ def _resolve_port(host: str, port: int, *, explicit: bool) -> int:
78
+ """Return the port to actually bind: ``port`` unchanged if it's free (or
79
+ if the caller passed ``--port`` explicitly — an explicit port that's busy
80
+ still errors the same way it does today, via the caller's own bind /
81
+ ``uvicorn.run``). Otherwise (default port, busy — the main app is often
82
+ already running on 8000, MAIN_PLAN #22) probe for a free OS-assigned
83
+ ephemeral port and return that instead, printing a note so the user
84
+ knows where the server actually came up.
85
+
86
+ Skips the bind probe entirely when ``explicit`` is set, so passing an
87
+ explicit ``--port`` never touches a socket here.
88
+ """
89
+ if explicit:
90
+ return port
91
+
92
+ sock = _bind(host, port)
93
+ if sock is not None:
94
+ sock.close()
95
+ return port
96
+
97
+ import socket
98
+
99
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as free:
100
+ free.bind((host, 0))
101
+ fallback = int(free.getsockname()[1])
102
+ print(f"[qz] port {port} is busy - using {fallback} instead")
103
+ return fallback
104
+
105
+
106
+ def _open_browser_later(url: str, delay: float = 2.0) -> None:
107
+ """Fixed-delay browser open — used only for the dev path, where the
108
+ target is the Vite server (no /api/health to poll)."""
109
+ import webbrowser
110
+
111
+ timer = threading.Timer(delay, webbrowser.open, [url])
112
+ timer.daemon = True # don't keep the process alive on Ctrl+C in --dev
113
+ timer.start()
114
+
115
+
116
+ def _open_when_healthy(url: str, host: str, port: int, timeout: float = 30.0) -> None:
117
+ """Open the browser only once the server answers — a fixed delay races a
118
+ cold numpy/scipy/matplotlib import and shows 'can't reach this page'.
119
+ Polls /api/health in a daemon thread; opens anyway after the timeout."""
120
+ import webbrowser
121
+
122
+ def _wait_then_open() -> None:
123
+ deadline = time.monotonic() + timeout
124
+ while time.monotonic() < deadline:
125
+ if _health_ok(host, port):
126
+ webbrowser.open(url)
127
+ return
128
+ time.sleep(0.25)
129
+ webbrowser.open(url) # last resort: open anyway after the timeout
130
+
131
+ threading.Thread(target=_wait_then_open, daemon=True).start()
132
+
133
+
134
+ _WEBVIEW_HINT = (
135
+ "--desktop needs pywebview: pip install quantized[desktop]\n"
136
+ "On Linux it also needs a native GUI backend (PyGObject + WebKitGTK, "
137
+ "e.g. `sudo apt install python3-gi gir1.2-webkit2-4.1`, or PyQt5/PySide2)."
138
+ )
139
+
140
+
141
+ def _run_desktop(
142
+ host: str,
143
+ port: int,
144
+ *,
145
+ title: str = "Quantized",
146
+ width: int = 1440,
147
+ height: int = 920,
148
+ path: str = "",
149
+ ) -> None:
150
+ """Native window: uvicorn in a thread, pywebview on top — pure Python, no
151
+ Rust toolchain (the Tauri shell in src-tauri/ is the packaged path).
152
+ Closing the window stops the server.
153
+
154
+ ``title``/``width``/``height``/``path`` let the ``--calc`` combo
155
+ (MAIN_PLAN #22) open a smaller window titled "DiraCulator" at
156
+ ``/?view=calc`` instead of the default full-app window; defaults are
157
+ the plain ``qz --desktop`` window unchanged."""
158
+ if not _WEB_DIR.is_dir():
159
+ print(
160
+ f"[qz] UI not built ({_WEB_DIR} missing). "
161
+ "Run: cd frontend && npm run build (or use the run launcher)."
162
+ )
163
+ return
164
+
165
+ try:
166
+ import webview
167
+ except ImportError as e:
168
+ print(f"[qz] {_WEBVIEW_HINT}\n(import error: {e})")
169
+ return
170
+
171
+ import uvicorn
172
+
173
+ from quantized.app import app
174
+
175
+ # Bind up front so a taken port is a clean branch, not a crashed server
176
+ # thread: reuse our own healthy instance (point the window at it), or
177
+ # refuse a foreign app instead of hanging 30 s on a dead window.
178
+ sock = _bind(host, port)
179
+ server: uvicorn.Server | None = None
180
+ t: threading.Thread | None = None
181
+ if sock is None:
182
+ if not _health_ok(host, port):
183
+ print(f"[qz] port {port} is in use by another app - close it and retry")
184
+ return
185
+ else:
186
+ server = uvicorn.Server(
187
+ uvicorn.Config(app, host=host, port=port, log_level="warning")
188
+ )
189
+ s = sock # bound socket handed to uvicorn — closes the bind race
190
+ t = threading.Thread(target=lambda: server.run(sockets=[s]), daemon=True)
191
+ t.start()
192
+
193
+ # Wait for the server to answer before pointing the window at it, else
194
+ # the webview shows a connection-refused page and never retries.
195
+ deadline = time.monotonic() + 30.0
196
+ while time.monotonic() < deadline and not _health_ok(host, port):
197
+ time.sleep(0.25)
198
+
199
+ try:
200
+ webview.create_window(
201
+ title,
202
+ f"http://{host}:{port}{path}",
203
+ width=width,
204
+ height=height,
205
+ background_color="#121116", # dark --surface-0 (oklch 0.16 0.008 280)
206
+ )
207
+ webview.start()
208
+ except Exception as e:
209
+ print(f"[qz] {_WEBVIEW_HINT}\n(error: {e})")
210
+ finally:
211
+ if server is not None:
212
+ server.should_exit = True
213
+ if t is not None:
214
+ t.join(timeout=5)
215
+
216
+
217
+ def _run_dev(host: str, port: int) -> None:
218
+ """Vite dev server (HMR) + auto-reloading uvicorn in one terminal."""
219
+ import os
220
+ import subprocess
221
+
222
+ import uvicorn
223
+
224
+ frontend = _frontend_dir()
225
+ if not frontend.is_dir():
226
+ print(f"[qz] --dev requires a source checkout; frontend/ not found at {frontend}")
227
+ raise SystemExit(2)
228
+ npm = "npm.cmd" if os.name == "nt" else "npm"
229
+ # Tell the Vite proxy which backend port to target (vite.config.ts reads
230
+ # QZ_BACKEND_PORT; review 2026-07-11: it was hardcoded to 8000, so
231
+ # `qz --dev --port 9000` silently proxied /api to the WRONG server).
232
+ env = dict(os.environ, QZ_BACKEND_PORT=str(port))
233
+ vite = subprocess.Popen([npm, "run", "dev"], cwd=frontend, env=env)
234
+ _open_browser_later("http://localhost:5173")
235
+ try:
236
+ uvicorn.run("quantized.app:app", host=host, port=port, reload=True)
237
+ finally:
238
+ # Windows: terminate() only kills the npm.cmd wrapper and orphans the
239
+ # node/Vite child holding :5173 (review 2026-07-11) - kill the tree.
240
+ if os.name == "nt":
241
+ subprocess.run(
242
+ ["taskkill", "/T", "/F", "/PID", str(vite.pid)],
243
+ capture_output=True,
244
+ check=False,
245
+ )
246
+ else:
247
+ vite.terminate()
248
+ try:
249
+ vite.wait(timeout=10)
250
+ except subprocess.TimeoutExpired:
251
+ pass # never mask the real exit path from inside finally