diff --git a/README.md b/README.md
index afa44c0..8d9898b 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,22 @@ It turns a repetitive manual lab workflow into a layered application:
- apply reference calibration
- export MAT/CSV/TXT data and optional plot images
+## Demo
+
+This capture shows the real Tkinter operator console replaying a deterministic 72-point fixture
+point by point. It demonstrates the UI, plotting, progress/status updates, source receipt, and export
+workflow without connected instruments.
+
+It is labeled `No hardware - simulated fixture`: the production AWG/oscilloscope adapters are not used
+in this demo, and it is not live hardware validation.
+
+
+
+[Open the demo video](docs/images/auto-load-off-test-point-replay-demo.mp4) |
+[View the poster frame](docs/images/auto-load-off-test-point-replay-demo.png)
+
## Why It Exists
Manual AWG/oscilloscope sweep measurements are repetitive and easy to misconfigure. This project separates the workflow into testable layers so the sweep math, signal processing, settings serialization, and use-case flow can be verified without physical instruments.
@@ -119,6 +135,10 @@ Example result generated from `demo_data/Demo(2).mat`:

+For application-material capture without connected instruments, see the deterministic Hyperframe fixture in
+`demo_data/hyperframe_simulated_fixture.*`. It should be labeled `No hardware - simulated fixture` and is not live
+hardware validation.
+
## Safety Notes
This is a local lab automation tool, not a certified production test platform. Operators are responsible for confirming the connected instrument model, address, voltage range, frequency range, impedance, coupling, and device-under-test limits before running a live sweep.
@@ -132,6 +152,8 @@ See [docs/safety.md](docs/safety.md) for stop/shutdown behavior and hardware ass
- [Safety Notes](docs/safety.md)
- [Extending The Application](docs/extending.md)
- [Case Study](docs/case_study.md)
+- [Hyperframe Demo Fixture](docs/hyperframe_demo.md)
+- [Hyperframe Capture Plan](docs/hyperframe_capture_plan.md)
- [Demo Data](demo_data/README.md)
## Project Status
diff --git a/demo_data/README.md b/demo_data/README.md
index 84c1871..c2e177b 100644
--- a/demo_data/README.md
+++ b/demo_data/README.md
@@ -4,6 +4,27 @@ This folder contains sample MAT files that can be used to inspect the measuremen
## Files
+- `hyperframe_simulated_fixture.mat`
+ - Contains 72 logarithmic frequency points from 1 kHz to 1 MHz.
+ - Source metadata: `source=mock_fixture`.
+ - Visible demo label: `Simulated no-hardware demo fixture`.
+ - Includes `freq_hz`, `gain_linear`, `gain_db`, `phase_deg`, raw gain/phase,
+ reference gain/phase, and corrected gain/phase fields.
+ - Useful for Hyperframe or portfolio capture when no AWG/oscilloscope hardware is connected.
+
+- `hyperframe_simulated_fixture.csv`
+ - Human-readable version of the same simulated fixture.
+ - Includes the same source label and raw/reference/corrected measurement columns.
+
+- `hyperframe_simulated_fixture.txt`
+ - Tab-separated numeric export for quick inspection.
+
+- `hyperframe_reference_fixture.mat`
+ - Reference calibration curve paired with the Hyperframe simulated fixture.
+
+- `hyperframe_simulated_fixture_metadata.json`
+ - Human-readable metadata and validation boundary.
+
- `Deme(1).mat`
- Contains 15 frequency points.
- Keys observed: `freq`, `gain_db_raw`, `config`.
@@ -17,8 +38,16 @@ This folder contains sample MAT files that can be used to inspect the measuremen
## How To Use
1. Start the desktop app with `python src/main.py`.
-2. Use the load-measurement action.
-3. Select one of the MAT files in this directory.
-4. Confirm the plot and loaded point count look reasonable.
+2. Use `Load Demo Fixture` for the Hyperframe fixture, or use the load-measurement action.
+3. If loading manually, select one of the MAT files in this directory.
+4. Confirm the plot, source receipt, no-hardware label, and point count look reasonable.
These files are sample data for review and local testing. They are not a substitute for live instrument verification.
+
+The Hyperframe fixture is explicitly simulated no-hardware data. Do not describe it as a live hardware validation run.
+
+Regenerate the deterministic Hyperframe fixture with:
+
+```bash
+python scripts/generate_hyperframe_fixture.py
+```
diff --git a/demo_data/hyperframe_reference_fixture.mat b/demo_data/hyperframe_reference_fixture.mat
new file mode 100644
index 0000000..30c3d13
Binary files /dev/null and b/demo_data/hyperframe_reference_fixture.mat differ
diff --git a/demo_data/hyperframe_simulated_fixture.csv b/demo_data/hyperframe_simulated_fixture.csv
new file mode 100644
index 0000000..b5e15c1
--- /dev/null
+++ b/demo_data/hyperframe_simulated_fixture.csv
@@ -0,0 +1,73 @@
+source,demo_label,freq_hz,gain_linear,gain_db,phase_deg,gain_db_raw,phase_deg_raw,gain_db_reference,phase_deg_reference,gain_db_corrected,phase_deg_corrected
+mock_fixture,Simulated no-hardware demo fixture,1000.0,1.0946422538089502,0.7854441628443293,-0.6891953079929891,0.5427086831566876,-1.819838384984986,-0.24273547968764164,-1.1306430769919968,0.7854441628443293,-0.6891953079929891
+mock_fixture,Simulated no-hardware demo fixture,1102.1825252050103,1.1030853862666787,0.8521826214091196,-0.4506312751653002,0.6438734161651408,-1.6364143982189145,-0.2083092052439788,-1.1857831230536144,0.8521826214091196,-0.4506312751653002
+mock_fixture,Simulated no-hardware demo fixture,1214.806318867293,1.1090352927747062,0.8989073380133354,-0.15009826256576372,0.6827368587406322,-1.3748160984673785,-0.21617047927270325,-1.2247178359016149,0.8989073380133354,-0.15009826256576372
+mock_fixture,Simulated no-hardware demo fixture,1338.9382961641559,1.1055476913567062,0.8715496401726136,-0.10184045653966808,0.6722002834469316,-1.6368006570294358,-0.19934935672568202,-1.5349602004897678,0.8715496401726136,-0.10184045653966808
+mock_fixture,Simulated no-hardware demo fixture,1475.754392359903,1.109527309139218,0.9027599224827991,0.20496230238913737,0.7501646391436538,-1.2848443658957378,-0.15259528333914524,-1.4898066682848752,0.9027599224827991,0.20496230238913737
+mock_fixture,Simulated no-hardware demo fixture,1626.550702753625,1.0930251482963478,0.7726030860331818,0.7582279819779297,0.6002646641900066,-0.7855282348117071,-0.1723384218431753,-1.5437562167896368,0.7726030860331818,0.7582279819779297
+mock_fixture,Simulated no-hardware demo fixture,1792.7557609349744,1.0890406327112643,0.7408816765311327,-0.34665701510398317,0.5609803906230828,-2.179294117701339,-0.17990128590804993,-1.8326371025973558,0.7408816765311327,-0.34665701510398317
+mock_fixture,Simulated no-hardware demo fixture,1975.9440716631398,1.0760857517957523,0.6369376208045417,-0.11072942638525396,0.46835051534456107,-1.7122494677400253,-0.16858710545998068,-1.6015200413547714,0.6369376208045417,-0.11072942638525396
+mock_fixture,Simulated no-hardware demo fixture,2177.851026569549,1.079580181326204,0.6650980656763963,-0.10895098783301096,0.4946578923090651,-1.8336790097319142,-0.17044017336733122,-1.7247280218989032,0.6650980656763963,-0.10895098783301096
+mock_fixture,Simulated no-hardware demo fixture,2400.3893439847493,1.0680593798731661,0.5719079683521333,-0.391956967661425,0.4033409130121872,-2.2428692726628165,-0.16856705533994612,-1.8509123050013916,0.5719079683521333,-0.391956967661425
+mock_fixture,Simulated no-hardware demo fixture,2645.667188628309,1.070611049769633,0.5926344284209522,-0.6724716651741651,0.3834558996404337,-2.567990191960609,-0.20917852878051849,-1.895518526786444,0.5926344284209522,-0.6724716651741651
+mock_fixture,Simulated no-hardware demo fixture,2916.0081428143894,1.0580779940373812,0.4903536400250969,-0.7267185251557238,0.25847774522796463,-2.7559370604295528,-0.2318758947971323,-2.029218535273829,0.4903536400250969,-0.7267185251557238
+mock_fixture,Simulated no-hardware demo fixture,3213.973218365539,1.0631242673911794,0.5316806336076115,-1.2749970503769608,0.3119273076970441,-3.607510951777561,-0.21975332591056745,-2.3325139014006004,0.5316806336076115,-1.2749970503769608
+mock_fixture,Simulated no-hardware demo fixture,3542.3851177594,1.0657362168132019,0.5529944923336172,-1.2117023419713633,0.339323079845925,-3.4305786186949145,-0.21367141248769217,-2.2188762767235515,0.5529944923336172,-1.2117023419713633
+mock_fixture,Simulated no-hardware demo fixture,3904.354974340707,1.0890587470214983,0.7410261501038539,-1.253677446818779,0.45965668524959935,-3.429830689267945,-0.2813694648542545,-2.176153242449166,0.7410261501038539,-1.253677446818779
+mock_fixture,Simulated no-hardware demo fixture,4303.311824915583,1.0924455243367104,0.7679957948660431,-1.314578942616869,0.48780381124294847,-3.5195038641371266,-0.2801919836230946,-2.2049249215202575,0.7679957948660431,-1.314578942616869
+mock_fixture,Simulated no-hardware demo fixture,4743.035093930039,1.0941957622637413,0.7819005691098199,-2.2556420142198657,0.4439744713274515,-4.964344873018286,-0.3379260977823684,-2.7087028587984197,0.7819005691098199,-2.2556420142198657
+mock_fixture,Simulated no-hardware demo fixture,5227.690396963792,1.1134819783141334,0.9336638475706286,-2.9379792322181597,0.5907459575445023,-5.513333898170721,-0.34291789002612627,-2.575354665952561,0.9336638475706286,-2.9379792322181597
+mock_fixture,Simulated no-hardware demo fixture,5761.869002715535,1.1142943872196376,0.9399988590342265,-3.3317383049940603,0.5920579254992557,-6.303386495242096,-0.3479409335349708,-2.971648190248036,0.9399988590342265,-3.3317383049940603
+mock_fixture,Simulated no-hardware demo fixture,6350.631327313489,1.1043712208517247,0.8623016140322844,-3.267968251772771,0.4936827184725006,-6.329096987452625,-0.36861889555978383,-3.0611287356798544,0.8623016140322844,-3.267968251772771
+mock_fixture,Simulated no-hardware demo fixture,6999.55487298442,1.1030294240311438,0.8517419536799594,-4.086427233317032,0.4671322967052247,-7.3037464441994455,-0.3846096569747347,-3.217319210882413,0.8517419536799594,-4.086427233317032
+mock_fixture,Simulated no-hardware demo fixture,7714.78706521701,1.1016662825128736,0.841001153248979,-4.7774227300086425,0.40449962667816575,-8.256281715356259,-0.4365015265708132,-3.478858985347616,0.841001153248979,-4.7774227300086425
+mock_fixture,Simulated no-hardware demo fixture,8503.103488959834,1.0766959018664453,0.6418612007780042,-4.968323449367176,0.18974476160085668,-8.517827909545058,-0.45211643917714756,-3.5495044601778827,0.6418612007780042,-4.968323449367176
+mock_fixture,Simulated no-hardware demo fixture,9371.972075541284,1.0744434141505295,0.6236709638961023,-4.879980959911257,0.1785690864081071,-8.487607615151482,-0.44510187748799523,-3.6076266552402245,0.6236709638961023,-4.879980959911257
+mock_fixture,Simulated no-hardware demo fixture,10329.623848370942,1.0726126973017591,0.6088586743841727,-5.59002708114435,0.14923927665725611,-9.542276780673372,-0.4596193977269166,-3.952249699529023,0.6088586743841727,-5.59002708114435
+mock_fixture,Simulated no-hardware demo fixture,11385.130897615369,1.073400813677327,0.6152384032468877,-6.280990557008165,0.17109268786034443,-10.085586753880994,-0.4441457153865433,-3.8045961968728292,0.6152384032468877,-6.280990557008165
+mock_fixture,Simulated no-hardware demo fixture,12548.49232252328,1.0717695793142976,0.6020285207794042,-6.672734238510627,0.1608321486359332,-10.880093496478331,-0.44119637214347096,-4.207359257967704,0.6020285207794042,-6.672734238510627
+mock_fixture,Simulated no-hardware demo fixture,13830.728955554407,1.0877875846466423,0.730881954627648,-6.584323143355444,0.2711369818132597,-10.62178556246597,-0.45974497281438836,-4.037462419110526,0.730881954627648,-6.584323143355444
+mock_fixture,Simulated no-hardware demo fixture,15243.987765659023,1.1118838634855213,0.9211885492122283,-7.04492011734629,0.49755134796436323,-11.87190375282135,-0.423637201247865,-4.8269836354750595,0.9211885492122283,-7.04492011734629
+mock_fixture,Simulated no-hardware demo fixture,16801.65692974833,1.14092589390957,1.1451487356677243,-7.102201014568578,0.7313804346144094,-11.712807823558741,-0.41376830105331497,-4.610606808990163,1.1451487356677243,-7.102201014568578
+mock_fixture,Simulated no-hardware demo fixture,18518.49266245829,1.1729073748797423,1.3852743400611576,-7.394773960620778,0.9806030948418394,-12.185655410031847,-0.40467124521931813,-4.790881449411069,1.3852743400611576,-7.394773960620778
+mock_fixture,Simulated no-hardware demo fixture,20410.75900569875,1.204600059547849,1.6168576060669861,-7.613075562206706,1.1781309476569035,-12.307884018118084,-0.43872665841008274,-4.694808455911377,1.6168576060669861,-7.613075562206706
+mock_fixture,Simulated no-hardware demo fixture,22496.38190225193,1.2458495332912987,1.9093118768441764,-8.251652761201521,1.5097963733142257,-13.135810729687101,-0.39951550352995074,-4.884157968485581,1.9093118768441764,-8.251652761201521
+mock_fixture,Simulated no-hardware demo fixture,24795.119013000298,1.265529183972237,2.045443294776188,-8.132684308265707,1.6428495412246833,-13.025107370310145,-0.40259375355150484,-4.892423062044437,2.045443294776188,-8.132684308265707
+mock_fixture,Simulated no-hardware demo fixture,27328.74688650746,1.2623261546666245,2.0234316127625864,-8.739020768515829,1.674140524245908,-13.786669303500219,-0.34929108851667845,-5.04764853498439,2.0234316127625864,-8.739020768515829
+mock_fixture,Simulated no-hardware demo fixture,30121.26725405938,1.2409500979016896,1.8750863529129376,-9.843562487750447,1.5311949591568923,-15.020878716590925,-0.3438913937560452,-5.177316228840478,1.8750863529129376,-9.843562487750447
+mock_fixture,Simulated no-hardware demo fixture,33199.13440445412,1.214891672343167,1.6907511027029785,-10.952124040463387,1.3359403691450638,-16.459947322422856,-0.3548107335579147,-5.507823281959469,1.6907511027029785,-10.952124040463387
+mock_fixture,Simulated no-hardware demo fixture,36591.50579252181,1.169428210448313,1.3594713247312733,-12.253343060063814,1.016168837368306,-17.402992942114793,-0.34330248736296726,-5.149649882050977,1.3594713247312733,-12.253343060063814
+mock_fixture,Simulated no-hardware demo fixture,40330.518255455485,1.1251898528026107,1.0245161390473423,-13.69249535823427,0.6640002561078098,-19.317639627654557,-0.3605158829395325,-5.625144269420287,1.0245161390473423,-13.69249535823427
+mock_fixture,Simulated no-hardware demo fixture,44451.592453624646,1.0725371965345833,0.6082472566613981,-16.536056185907313,0.2418275948074053,-21.959832592956428,-0.36641966185399283,-5.423776407049114,0.6082472566613981,-16.536056185907313
+mock_fixture,Simulated no-hardware demo fixture,48993.76841991994,1.032028211454676,0.27383138597171314,-18.376071849531723,-0.06382387242966109,-24.231220009464185,-0.3376552584013742,-5.855148159932461,0.27383138597171314,-18.376071849531723
+mock_fixture,Simulated no-hardware demo fixture,54000.075396376895,1.0016977461118273,0.014733931659211205,-21.405726382972276,-0.3919497953583512,-26.865227392315457,-0.40668372701756245,-5.459501009343181,0.014733931659211205,-21.405726382972276
+mock_fixture,Simulated no-hardware demo fixture,59517.9394616397,0.9690341643714757,-0.2732182229382106,-23.925270844289212,-0.7037382074778895,-29.66865135346927,-0.430519984539679,-5.743380509180056,-0.2732182229382106,-23.925270844289212
+mock_fixture,Simulated no-hardware demo fixture,65599.6328108289,0.9310541831923521,-0.620500885706677,-27.231412611169713,-1.0503739404726544,-33.259102397748734,-0.4298730547659775,-6.027689786579023,-0.620500885706677,-27.231412611169713
+mock_fixture,Simulated no-hardware demo fixture,72302.76894396091,0.9143500056748429,-0.7777505615537622,-29.733250983892678,-1.2356482938547355,-35.48528306622329,-0.4578977323009734,-5.752032082330609,-0.7777505615537622,-29.733250983892678
+mock_fixture,Simulated no-hardware demo fixture,79690.84845396914,0.8956743662778601,-0.956997097677451,-33.19617413505907,-1.409890532731443,-39.01776198736164,-0.452893435053992,-5.8215878523025735,-0.956997097677451,-33.19617413505907
+mock_fixture,Simulated no-hardware demo fixture,87833.86058472558,0.8724386965372865,-1.1853015954774035,-36.25665014834134,-1.6755438035137524,-42.258813924360496,-0.4902422080363488,-6.002163776019156,-1.1853015954774035,-36.25665014834134
+mock_fixture,Simulated no-hardware demo fixture,96808.94625777756,0.8421120650814955,-1.4926022078216314,-38.72348803673635,-1.9803225091990815,-44.8937286312756,-0.48772030137745004,-6.170240594539251,-1.4926022078216314,-38.72348803673635
+mock_fixture,Simulated no-hardware demo fixture,106701.1288488335,0.8048462560070622,-1.8857414372305839,-42.4145498421253,-2.4124595928233328,-48.57631203110761,-0.526718155592749,-6.161762188982318,-1.8857414372305839,-42.4145498421253
+mock_fixture,Simulated no-hardware demo fixture,117604.11963683259,0.782589271079458,-2.1293222072380247,-44.771649717694416,-2.68367816005845,-50.75052701873499,-0.5543559528204253,-5.978877301040574,-2.1293222072380247,-44.771649717694416
+mock_fixture,Simulated no-hardware demo fixture,129621.20555583615,0.7352514310620851,-2.6712824305555833,-48.42440672587049,-3.2452568967767768,-54.63321510916947,-0.5739744662211932,-6.208808383298976,-2.6712824305555833,-48.42440672587049
+mock_fixture,Simulated no-hardware demo fixture,142866.22765964904,0.6856822804396959,-3.2775414705640356,-51.495751628478786,-3.9023816141384415,-57.657704825522025,-0.6248401435744058,-6.161953197043241,-3.2775414705640356,-51.495751628478786
+mock_fixture,Simulated no-hardware demo fixture,157464.65956842602,0.6396349565518293,-3.881356194866291,-54.22325861764345,-4.493499804857033,-60.47396680180035,-0.6121436099907426,-6.250708184156902,-3.881356194866291,-54.22325861764345
+mock_fixture,Simulated no-hardware demo fixture,173554.79611367523,0.5951868940092687,-4.5069328100675214,-57.238185318764565,-5.140026735650732,-63.700968193613654,-0.6330939255832109,-6.462782874849092,-4.5069328100675214,-57.238185318764565
+mock_fixture,Simulated no-hardware demo fixture,191289.06344201107,0.5518245431778371,-5.163979751306178,-59.61019130039137,-5.771588245345932,-66.1086460301845,-0.6076084940397545,-6.498454729793136,-5.163979751306178,-59.61019130039137
+mock_fixture,Simulated no-hardware demo fixture,210835.46298861736,0.5097805765118311,-5.852334317877693,-62.038052859072295,-6.468461407220598,-68.58307714515038,-0.6161270893429054,-6.545024286078085,-5.852334317877693,-62.038052859072295
+mock_fixture,Simulated no-hardware demo fixture,232379.16299956202,0.4773282515342687,-6.423657207402327,-63.7361909783563,-7.062680778477102,-70.47625791151421,-0.6390235710747745,-6.740066933157912,-6.423657207402327,-63.7361909783563
+mock_fixture,Simulated no-hardware demo fixture,256124.25267988368,0.4371701610064753,-7.186989768503312,-66.39176317229496,-7.825228160184892,-73.4166992202608,-0.6382383916815796,-7.024936047965838,-7.186989768503312,-66.39176317229496
+mock_fixture,Simulated no-hardware demo fixture,282295.67558496,0.4030387156555061,-7.893064675790587,-68.48933732666343,-8.495775099838877,-75.53014149669359,-0.6027104240482901,-7.04080417003016,-7.893064675790587,-68.48933732666343
+mock_fixture,Simulated no-hardware demo fixture,311141.3605706859,0.3741131155877412,-8.539941322401223,-70.2231831939938,-9.117331535478431,-77.25103131435765,-0.5773902130772083,-7.027848120363849,-8.539941322401223,-70.2231831939938
+mock_fixture,Simulated no-hardware demo fixture,342934.5704895215,0.34747434122354504,-9.181545195661736,-71.77378474492475,-9.766828836176112,-79.02219832617592,-0.5852836405143764,-7.248413581251171,-9.181545195661736,-71.77378474492475
+mock_fixture,Simulated no-hardware demo fixture,377976.490882236,0.3181561887017308,-9.947192490964618,-73.99390484567766,-10.526134732838715,-81.41066005973138,-0.5789422418740965,-7.416755214053728,-9.947192490964618,-73.99390484567766
+mock_fixture,Simulated no-hardware demo fixture,416599.08318871184,0.29307733421414983,-10.660355347802579,-75.19535305978168,-11.246340565702692,-82.55460902720353,-0.5859852179001138,-7.359255967421844,-10.660355347802579,-75.19535305978168
+mock_fixture,Simulated no-hardware demo fixture,459168.22950702696,0.2706144491415213,-11.352980369483367,-76.97774723539014,-11.976796162916035,-84.70670205497534,-0.623815793432668,-7.728954819585197,-11.352980369483367,-76.97774723539014
+mock_fixture,Simulated no-hardware demo fixture,506087.19869196817,0.24487791201551,-12.221007729306256,-77.42268365747306,-12.792427992105178,-85.22776178943184,-0.5714202627989224,-7.805078131958782,-12.221007729306256,-77.42268365747306
+mock_fixture,Simulated no-hardware demo fixture,557800.4666282426,0.22137078547970412,-13.09759387891318,-79.13847218779563,-13.63148198156949,-87.04413557511447,-0.5338881026563097,-7.905663387318836,-13.09759387891318,-79.13847218779563
+mock_fixture,Simulated no-hardware demo fixture,614797.9268688501,0.20140359067910785,-13.918655819816644,-79.6106508302024,-14.444635211214212,-87.59889607651428,-0.5259793913975688,-7.98824524631187,-13.918655819816644,-79.6106508302024
+mock_fixture,Simulated no-hardware demo fixture,677619.5315271151,0.18114166054681222,-14.839633106932858,-80.75872934994489,-15.396048351739694,-89.1379745559753,-0.5564152448068347,-8.379245206030413,-14.839633106932858,-80.75872934994489
+mock_fixture,Simulated no-hardware demo fixture,746860.406386791,0.16435121992033155,-15.68454135915525,-80.85037213779485,-16.20660378667284,-89.31819300795213,-0.522062427517588,-8.467820870157286,-15.68454135915525,-80.85037213779485
+mock_fixture,Simulated no-hardware demo fixture,823176.4886870343,0.14918596698416586,-16.52544052761106,-81.91164183007417,-17.082338031679587,-90.34419201896046,-0.5568975040685262,-8.432550188886282,-16.52544052761106,-81.91164183007417
+mock_fixture,Simulated no-hardware demo fixture,907290.7409904699,0.13406895355252893,-17.45343560804899,-82.37070704537146,-17.98405181598562,-91.13246858315658,-0.5306162079366278,-8.761761537785125,-17.45343560804899,-82.37070704537146
+mock_fixture,Simulated no-hardware demo fixture,1000000.0,0.1214338868637889,-18.31320207669061,-83.04382503851535,-18.886987838708855,-91.79585655615504,-0.5737857620182423,-8.752031517639699,-18.31320207669061,-83.04382503851535
diff --git a/demo_data/hyperframe_simulated_fixture.mat b/demo_data/hyperframe_simulated_fixture.mat
new file mode 100644
index 0000000..ccd5b07
Binary files /dev/null and b/demo_data/hyperframe_simulated_fixture.mat differ
diff --git a/demo_data/hyperframe_simulated_fixture.txt b/demo_data/hyperframe_simulated_fixture.txt
new file mode 100644
index 0000000..f925f4e
--- /dev/null
+++ b/demo_data/hyperframe_simulated_fixture.txt
@@ -0,0 +1,74 @@
+source=mock_fixture; label=Simulated no-hardware demo fixture; boundary=No hardware - simulated fixture; not live hardware validation.
+freq_hz gain_linear gain_db phase_deg gain_db_raw phase_deg_raw gain_db_reference phase_deg_reference gain_db_corrected phase_deg_corrected
+1.000000000000000000e+03 1.094642253808950194e+00 7.854441628443292744e-01 -6.891953079929891146e-01 5.427086831566876324e-01 -1.819838384984985957e+00 -2.427354796876416421e-01 -1.130643076991996843e+00 7.854441628443292744e-01 -6.891953079929891146e-01
+1.102182525205010279e+03 1.103085386266678736e+00 8.521826214091196006e-01 -4.506312751653002158e-01 6.438734161651408217e-01 -1.636414398218914545e+00 -2.083092052439788067e-01 -1.185783123053614441e+00 8.521826214091196006e-01 -4.506312751653002158e-01
+1.214806318867292930e+03 1.109035292774706249e+00 8.989073380133354441e-01 -1.500982625657637237e-01 6.827368587406321687e-01 -1.374816098467378511e+00 -2.161704792727032476e-01 -1.224717835901614871e+00 8.989073380133354441e-01 -1.500982625657637237e-01
+1.338938296164155872e+03 1.105547691356706164e+00 8.715496401726136044e-01 -1.018404565396680839e-01 6.722002834469316124e-01 -1.636800657029435779e+00 -1.993493567256820198e-01 -1.534960200489767779e+00 8.715496401726136044e-01 -1.018404565396680839e-01
+1.475754392359903022e+03 1.109527309139217932e+00 9.027599224827991264e-01 2.049623023891373741e-01 7.501646391436538330e-01 -1.284844365895737806e+00 -1.525952833391452379e-01 -1.489806668284875180e+00 9.027599224827991264e-01 2.049623023891373741e-01
+1.626550702753625046e+03 1.093025148296347782e+00 7.726030860331818406e-01 7.582279819779297325e-01 6.002646641900065694e-01 -7.855282348117070823e-01 -1.723384218431752990e-01 -1.543756216789636815e+00 7.726030860331818406e-01 7.582279819779297325e-01
+1.792755760934974433e+03 1.089040632711264278e+00 7.408816765311326824e-01 -3.466570151039831682e-01 5.609803906230828030e-01 -2.179294117701338962e+00 -1.799012859080499349e-01 -1.832637102597355794e+00 7.408816765311326824e-01 -3.466570151039831682e-01
+1.975944071663139766e+03 1.076085751795752277e+00 6.369376208045417487e-01 -1.107294263852539595e-01 4.683505153445610691e-01 -1.712249467740025288e+00 -1.685871054599806795e-01 -1.601520041354771440e+00 6.369376208045417487e-01 -1.107294263852539595e-01
+2.177851026569549049e+03 1.079580181326204036e+00 6.650980656763962884e-01 -1.089509878330109649e-01 4.946578923090650948e-01 -1.833679009731914222e+00 -1.704401733673312214e-01 -1.724728021898903174e+00 6.650980656763962884e-01 -1.089509878330109649e-01
+2.400389343984749303e+03 1.068059379873166126e+00 5.719079683521333113e-01 -3.919569676614250153e-01 4.033409130121872188e-01 -2.242869272662816549e+00 -1.685670553399461202e-01 -1.850912305001391589e+00 5.719079683521333113e-01 -3.919569676614250153e-01
+2.645667188628308850e+03 1.070611049769633061e+00 5.926344284209521529e-01 -6.724716651741651230e-01 3.834558996404336950e-01 -2.567990191960609181e+00 -2.091785287805184856e-01 -1.895518526786444058e+00 5.926344284209521529e-01 -6.724716651741651230e-01
+2.916008142814389430e+03 1.058077994037381231e+00 4.903536400250969041e-01 -7.267185251557237713e-01 2.584777452279646281e-01 -2.755937060429552776e+00 -2.318758947971323037e-01 -2.029218535273829005e+00 4.903536400250969041e-01 -7.267185251557237713e-01
+3.213973218365539196e+03 1.063124267391179378e+00 5.316806336076115302e-01 -1.274997050376960761e+00 3.119273076970440761e-01 -3.607510951777561203e+00 -2.197533259105674541e-01 -2.332513901400600442e+00 5.316806336076115302e-01 -1.274997050376960761e+00
+3.542385117759400146e+03 1.065736216813201853e+00 5.529944923336171936e-01 -1.211702341971363284e+00 3.393230798459250241e-01 -3.430578618694914539e+00 -2.136714124876921694e-01 -2.218876276723551477e+00 5.529944923336171936e-01 -1.211702341971363284e+00
+3.904354974340707031e+03 1.089058747021498297e+00 7.410261501038538645e-01 -1.253677446818779018e+00 4.596566852495993505e-01 -3.429830689267944877e+00 -2.813694648542545140e-01 -2.176153242449165859e+00 7.410261501038538645e-01 -1.253677446818779018e+00
+4.303311824915583202e+03 1.092445524336710383e+00 7.679957948660430800e-01 -1.314578942616869028e+00 4.878038112429484707e-01 -3.519503864137126570e+00 -2.801919836230946093e-01 -2.204924921520257541e+00 7.679957948660430800e-01 -1.314578942616869028e+00
+4.743035093930038784e+03 1.094195762263741267e+00 7.819005691098198652e-01 -2.255642014219865654e+00 4.439744713274514920e-01 -4.964344873018285753e+00 -3.379260977823683731e-01 -2.708702858798419655e+00 7.819005691098198652e-01 -2.255642014219865654e+00
+5.227690396963792409e+03 1.113481978314133425e+00 9.336638475706285911e-01 -2.937979232218159709e+00 5.907459575445023203e-01 -5.513333898170721170e+00 -3.429178900261262708e-01 -2.575354665952561017e+00 9.336638475706285911e-01 -2.937979232218159709e+00
+5.761869002715535316e+03 1.114294387219637583e+00 9.399988590342265304e-01 -3.331738304994060318e+00 5.920579254992557328e-01 -6.303386495242095933e+00 -3.479409335349707977e-01 -2.971648190248036059e+00 9.399988590342265304e-01 -3.331738304994060318e+00
+6.350631327313489237e+03 1.104371220851724678e+00 8.623016140322844469e-01 -3.267968251772770838e+00 4.936827184725006146e-01 -6.329096987452625278e+00 -3.686188955597838324e-01 -3.061128735679854440e+00 8.623016140322844469e-01 -3.267968251772770838e+00
+6.999554872984420399e+03 1.103029424031143790e+00 8.517419536799594182e-01 -4.086427233317031948e+00 4.671322967052247144e-01 -7.303746444199445520e+00 -3.846096569747347038e-01 -3.217319210882413127e+00 8.517419536799594182e-01 -4.086427233317031948e+00
+7.714787065217010422e+03 1.101666282512873574e+00 8.410011532489789499e-01 -4.777422730008642482e+00 4.044996266781657468e-01 -8.256281715356259099e+00 -4.365015265708132031e-01 -3.478858985347616173e+00 8.410011532489789499e-01 -4.777422730008642482e+00
+8.503103488959834067e+03 1.076695901866445260e+00 6.418612007780042372e-01 -4.968323449367176181e+00 1.897447616008566817e-01 -8.517827909545058418e+00 -4.521164391771475555e-01 -3.549504460177882681e+00 6.418612007780042372e-01 -4.968323449367176181e+00
+9.371972075541283630e+03 1.074443414150529463e+00 6.236709638961023261e-01 -4.879980959911256910e+00 1.785690864081070917e-01 -8.487607615151482321e+00 -4.451018774879952344e-01 -3.607626655240224522e+00 6.236709638961023261e-01 -4.879980959911256910e+00
+1.032962384837094214e+04 1.072612697301759122e+00 6.088586743841727378e-01 -5.590027081144349985e+00 1.492392766572561147e-01 -9.542276780673372372e+00 -4.596193977269166231e-01 -3.952249699529022831e+00 6.088586743841727378e-01 -5.590027081144349985e+00
+1.138513089761536867e+04 1.073400813677326981e+00 6.152384032468877217e-01 -6.280990557008164998e+00 1.710926878603444279e-01 -1.008558675388099424e+01 -4.441457153865432939e-01 -3.804596196872829239e+00 6.152384032468877217e-01 -6.280990557008164998e+00
+1.254849232252327965e+04 1.071769579314297616e+00 6.020285207794041638e-01 -6.672734238510627414e+00 1.608321486359332031e-01 -1.088009349647833091e+01 -4.411963721434709607e-01 -4.207359257967704380e+00 6.020285207794041638e-01 -6.672734238510627414e+00
+1.383072895555440664e+04 1.087787584646642269e+00 7.308819546276480450e-01 -6.584323143355444330e+00 2.711369818132596810e-01 -1.062178556246596983e+01 -4.597449728143883640e-01 -4.037462419110526390e+00 7.308819546276480450e-01 -6.584323143355444330e+00
+1.524398776565902335e+04 1.111883863485521262e+00 9.211885492122282582e-01 -7.044920117346290311e+00 4.975513479643632331e-01 -1.187190375282134980e+01 -4.236372012478650251e-01 -4.826983635475059486e+00 9.211885492122282582e-01 -7.044920117346290311e+00
+1.680165692974832928e+04 1.140925893909570110e+00 1.145148735667724349e+00 -7.102201014568578330e+00 7.313804346144093804e-01 -1.171280782355874095e+01 -4.137683010533149686e-01 -4.610606808990162619e+00 1.145148735667724349e+00 -7.102201014568578330e+00
+1.851849266245829131e+04 1.172907374879742282e+00 1.385274340061157572e+00 -7.394773960620778297e+00 9.806030948418393844e-01 -1.218565541003184727e+01 -4.046712452193181320e-01 -4.790881449411068971e+00 1.385274340061157572e+00 -7.394773960620778297e+00
+2.041075900569875012e+04 1.204600059547848989e+00 1.616857606066986142e+00 -7.613075562206706337e+00 1.178130947656903516e+00 -1.230788401811808441e+01 -4.387266584100827371e-01 -4.694808455911377187e+00 1.616857606066986142e+00 -7.613075562206706337e+00
+2.249638190225193102e+04 1.245849533291298661e+00 1.909311876844176359e+00 -8.251652761201521358e+00 1.509796373314225670e+00 -1.313581072968710117e+01 -3.995155035299507440e-01 -4.884157968485580703e+00 1.909311876844176359e+00 -8.251652761201521358e+00
+2.479511901300029785e+04 1.265529183972236904e+00 2.045443294776188026e+00 -8.132684308265707429e+00 1.642849541224683296e+00 -1.302510737031014543e+01 -4.025937535515048404e-01 -4.892423062044437110e+00 2.045443294776188026e+00 -8.132684308265707429e+00
+2.732874688650745884e+04 1.262326154666624456e+00 2.023431612762586429e+00 -8.739020768515828763e+00 1.674140524245907979e+00 -1.378666930350021858e+01 -3.492910885166784496e-01 -5.047648534984389812e+00 2.023431612762586429e+00 -8.739020768515828763e+00
+3.012126725405938123e+04 1.240950097901689642e+00 1.875086352912937571e+00 -9.843562487750446621e+00 1.531194959156892299e+00 -1.502087871659092499e+01 -3.438913937560452161e-01 -5.177316228840478374e+00 1.875086352912937571e+00 -9.843562487750446621e+00
+3.319913440445411834e+04 1.214891672343167084e+00 1.690751102702978459e+00 -1.095212404046338683e+01 1.335940369145063755e+00 -1.645994732242285608e+01 -3.548107335579147037e-01 -5.507823281959469242e+00 1.690751102702978459e+00 -1.095212404046338683e+01
+3.659150579252181342e+04 1.169428210448312910e+00 1.359471324731273301e+00 -1.225334306006381446e+01 1.016168837368305988e+00 -1.740299294211479264e+01 -3.433024873629672569e-01 -5.149649882050977290e+00 1.359471324731273301e+00 -1.225334306006381446e+01
+4.033051825545548490e+04 1.125189852802610746e+00 1.024516139047342289e+00 -1.369249535823426989e+01 6.640002561078097720e-01 -1.931763962765455744e+01 -3.605158829395325171e-01 -5.625144269420286669e+00 1.024516139047342289e+00 -1.369249535823426989e+01
+4.445159245362464571e+04 1.072537196534583348e+00 6.082472566613981169e-01 -1.653605618590731297e+01 2.418275948074052883e-01 -2.195983259295642753e+01 -3.664196618539928285e-01 -5.423776407049113679e+00 6.082472566613981169e-01 -1.653605618590731297e+01
+4.899376841991994297e+04 1.032028211454675937e+00 2.738313859717131371e-01 -1.837607184953172279e+01 -6.382387242966108820e-02 -2.423122000946418453e+01 -3.376552584013742253e-01 -5.855148159932460850e+00 2.738313859717131371e-01 -1.837607184953172279e+01
+5.400007539637689479e+04 1.001697746111827270e+00 1.473393165921120518e-02 -2.140572638297227570e+01 -3.919497953583512273e-01 -2.686522739231545742e+01 -4.066837270175624464e-01 -5.459501009343180833e+00 1.473393165921120518e-02 -2.140572638297227570e+01
+5.951793946163969667e+04 9.690341643714757147e-01 -2.732182229382105754e-01 -2.392527084428921214e+01 -7.037382074778895102e-01 -2.966865135346926863e+01 -4.305199845396789904e-01 -5.743380509180055604e+00 -2.732182229382105754e-01 -2.392527084428921214e+01
+6.559963281082890171e+04 9.310541831923521139e-01 -6.205008857066770522e-01 -2.723141261116971279e+01 -1.050373940472654422e+00 -3.325910239774873389e+01 -4.298730547659774803e-01 -6.027689786579022879e+00 -6.205008857066770522e-01 -2.723141261116971279e+01
+7.230276894396090938e+04 9.143500056748429250e-01 -7.777505615537622097e-01 -2.973325098389267751e+01 -1.235648293854735513e+00 -3.548528306622328898e+01 -4.578977323009734146e-01 -5.752032082330608809e+00 -7.777505615537622097e-01 -2.973325098389267751e+01
+7.969084845396914170e+04 8.956743662778601234e-01 -9.569970976774510030e-01 -3.319617413505906711e+01 -1.409890532731443047e+00 -3.901776198736163792e+01 -4.528934350539919884e-01 -5.821587852302573474e+00 -9.569970976774510030e-01 -3.319617413505906711e+01
+8.783386058472558216e+04 8.724386965372864511e-01 -1.185301595477403547e+00 -3.625665014834134325e+01 -1.675543803513752428e+00 -4.225881392436049566e+01 -4.902422080363488255e-01 -6.002163776019155961e+00 -1.185301595477403547e+00 -3.625665014834134325e+01
+9.680894625777755573e+04 8.421120650814954978e-01 -1.492602207821631444e+00 -3.872348803673634876e+01 -1.980322509199081482e+00 -4.489372863127560009e+01 -4.877203013774500384e-01 -6.170240594539251333e+00 -1.492602207821631444e+00 -3.872348803673634876e+01
+1.067011288488334976e+05 8.048462560070621663e-01 -1.885741437230583850e+00 -4.241454984212529666e+01 -2.412459592823332777e+00 -4.857631203110761220e+01 -5.267181555927490377e-01 -6.161762188982318200e+00 -1.885741437230583850e+00 -4.241454984212529666e+01
+1.176041196368325909e+05 7.825892710794579710e-01 -2.129322207238024678e+00 -4.477164971769441593e+01 -2.683678160058450057e+00 -5.075052701873499217e+01 -5.543559528204252684e-01 -5.978877301040573577e+00 -2.129322207238024678e+00 -4.477164971769441593e+01
+1.296212055558361462e+05 7.352514310620851257e-01 -2.671282430555583343e+00 -4.842440672587049022e+01 -3.245256896776776756e+00 -5.463321510916946977e+01 -5.739744662211931914e-01 -6.208808383298975997e+00 -2.671282430555583343e+00 -4.842440672587049022e+01
+1.428662276596490410e+05 6.856822804396959237e-01 -3.277541470564035553e+00 -5.149575162847878573e+01 -3.902381614138441535e+00 -5.765770482552202481e+01 -6.248401435744057597e-01 -6.161953197043240849e+00 -3.277541470564035553e+00 -5.149575162847878573e+01
+1.574646595684260246e+05 6.396349565518293367e-01 -3.881356194866290910e+00 -5.422325861764345234e+01 -4.493499804857033375e+00 -6.047396680180035133e+01 -6.121436099907425765e-01 -6.250708184156901659e+00 -3.881356194866290910e+00 -5.422325861764345234e+01
+1.735547961136752274e+05 5.951868940092687188e-01 -4.506932810067521444e+00 -5.723818531876456461e+01 -5.140026735650732093e+00 -6.370096819361365448e+01 -6.330939255832108703e-01 -6.462782874849091641e+00 -4.506932810067521444e+00 -5.723818531876456461e+01
+1.912890634420110728e+05 5.518245431778371346e-01 -5.163979751306177590e+00 -5.961019130039137082e+01 -5.771588245345932400e+00 -6.610864603018450225e+01 -6.076084940397544765e-01 -6.498454729793135876e+00 -5.163979751306177590e+00 -5.961019130039137082e+01
+2.108354629886173643e+05 5.097805765118310628e-01 -5.852334317877692982e+00 -6.203805285907229461e+01 -6.468461407220598147e+00 -6.858307714515038356e+01 -6.161270893429053874e-01 -6.545024286078085396e+00 -5.852334317877692982e+00 -6.203805285907229461e+01
+2.323791629995620169e+05 4.773282515342687216e-01 -6.423657207402326819e+00 -6.373619097835629788e+01 -7.062680778477101740e+00 -7.047625791151421026e+01 -6.390235710747744768e-01 -6.740066933157912388e+00 -6.423657207402326819e+00 -6.373619097835629788e+01
+2.561242526798836770e+05 4.371701610064753218e-01 -7.186989768503312170e+00 -6.639176317229495794e+01 -7.825228160184892090e+00 -7.341669922026079576e+01 -6.382383916815795866e-01 -7.024936047965837815e+00 -7.186989768503312170e+00 -6.639176317229495794e+01
+2.822956755849599722e+05 4.030387156555060857e-01 -7.893064675790586904e+00 -6.848933732666343133e+01 -8.495775099838876798e+00 -7.553014149669358801e+01 -6.027104240482901165e-01 -7.040804170030160236e+00 -7.893064675790586904e+00 -6.848933732666343133e+01
+3.111413605706858798e+05 3.741131155877411851e-01 -8.539941322401222834e+00 -7.022318319399380471e+01 -9.117331535478431448e+00 -7.725103131435764681e+01 -5.773902130772082808e-01 -7.027848120363849205e+00 -8.539941322401222834e+00 -7.022318319399380471e+01
+3.429345704895214876e+05 3.474743412235450357e-01 -9.181545195661735548e+00 -7.177378474492475391e+01 -9.766828836176111750e+00 -7.902219832617592488e+01 -5.852836405143764242e-01 -7.248413581251170967e+00 -9.181545195661735548e+00 -7.177378474492475391e+01
+3.779764908822359866e+05 3.181561887017307910e-01 -9.947192490964617662e+00 -7.399390484567766180e+01 -1.052613473283871492e+01 -8.141066005973138431e+01 -5.789422418740964860e-01 -7.416755214053727840e+00 -9.947192490964617662e+00 -7.399390484567766180e+01
+4.165990831887118402e+05 2.930773342141498317e-01 -1.066035534780257876e+01 -7.519535305978168083e+01 -1.124634056570269180e+01 -8.255460902720352578e+01 -5.859852179001138195e-01 -7.359255967421844069e+00 -1.066035534780257876e+01 -7.519535305978168083e+01
+4.591682295070269611e+05 2.706144491415212738e-01 -1.135298036948336708e+01 -7.697774723539014019e+01 -1.197679616291603466e+01 -8.470670205497533800e+01 -6.238157934326680198e-01 -7.728954819585196923e+00 -1.135298036948336708e+01 -7.697774723539014019e+01
+5.060871986919681658e+05 2.448779120155100131e-01 -1.222100772930625645e+01 -7.742268365747305836e+01 -1.279242799210517845e+01 -8.522776178943183822e+01 -5.714202627989224403e-01 -7.805078131958781640e+00 -1.222100772930625645e+01 -7.742268365747305836e+01
+5.578004666282426333e+05 2.213707854797041241e-01 -1.309759387891318028e+01 -7.913847218779562809e+01 -1.363148198156948965e+01 -8.704413557511446697e+01 -5.338881026563097043e-01 -7.905663387318836222e+00 -1.309759387891318028e+01 -7.913847218779562809e+01
+6.147979268688501325e+05 2.014035906791078467e-01 -1.391865581981664413e+01 -7.961065083020240252e+01 -1.444463521121421223e+01 -8.759889607651427923e+01 -5.259793913975687740e-01 -7.988245246311869607e+00 -1.391865581981664413e+01 -7.961065083020240252e+01
+6.776195315271151485e+05 1.811416605468122165e-01 -1.483963310693285820e+01 -8.075872934994488617e+01 -1.539604835173969377e+01 -8.913797455597530472e+01 -5.564152448068346857e-01 -8.379245206030413229e+00 -1.483963310693285820e+01 -8.075872934994488617e+01
+7.468604063867910299e+05 1.643512199203315538e-01 -1.568454135915525072e+01 -8.085037213779484944e+01 -1.620660378667284007e+01 -8.931819300795213223e+01 -5.220624275175880236e-01 -8.467820870157286350e+00 -1.568454135915525072e+01 -8.085037213779484944e+01
+8.231764886870343471e+05 1.491859669841658631e-01 -1.652544052761106030e+01 -8.191164183007417421e+01 -1.708233803167958698e+01 -9.034419201896045593e+01 -5.568975040685262279e-01 -8.432550188886281717e+00 -1.652544052761106030e+01 -8.191164183007417421e+01
+9.072907409904699307e+05 1.340689535525289300e-01 -1.745343560804899141e+01 -8.237070704537146071e+01 -1.798405181598561953e+01 -9.113246858315658017e+01 -5.306162079366277817e-01 -8.761761537785124787e+00 -1.745343560804899141e+01 -8.237070704537146071e+01
+1.000000000000000000e+06 1.214338868637889063e-01 -1.831320207669061162e+01 -8.304382503851535091e+01 -1.888698783870885478e+01 -9.179585655615504436e+01 -5.737857620182422735e-01 -8.752031517639698777e+00 -1.831320207669061162e+01 -8.304382503851535091e+01
diff --git a/demo_data/hyperframe_simulated_fixture_metadata.json b/demo_data/hyperframe_simulated_fixture_metadata.json
new file mode 100644
index 0000000..fc213fb
--- /dev/null
+++ b/demo_data/hyperframe_simulated_fixture_metadata.json
@@ -0,0 +1,11 @@
+{
+ "correction_mode": "dual",
+ "demo_label": "Simulated no-hardware demo fixture",
+ "generated_from": "src/app/demo/hyperframe_fixture.py",
+ "point_count": 72,
+ "source": "mock_fixture",
+ "sweep_start_hz": 1000.0,
+ "sweep_stop_hz": 1000000.0,
+ "trigger_mode": "triggered",
+ "validation_boundary": "No hardware - simulated fixture; not live hardware validation."
+}
diff --git a/docs/hyperframe_capture_plan.md b/docs/hyperframe_capture_plan.md
new file mode 100644
index 0000000..5637838
--- /dev/null
+++ b/docs/hyperframe_capture_plan.md
@@ -0,0 +1,117 @@
+# Hyperframe Fixture Replay Capture Plan
+
+This plan captures the deterministic fixture replay first. It does not add an app-level demo-mode banner
+and does not imply live hardware validation.
+
+Required visible label in the capture:
+
+```text
+No hardware - simulated fixture
+```
+
+or:
+
+```text
+Simulated no-hardware demo fixture
+```
+
+Keep the label small and persistent, for example in the lower-left corner or as a compact title overlay.
+
+## Current Tooling Status
+
+No `hyperframe` CLI or repo-local capture script is available in this workspace. The capture itself should
+be done in the coordinator's Hyperframe environment or by the person running the desktop capture.
+
+Available local inputs:
+
+- `demo_data/hyperframe_simulated_fixture.mat`
+- `demo_data/hyperframe_simulated_fixture.csv`
+- `demo_data/hyperframe_simulated_fixture.txt`
+- `demo_data/hyperframe_reference_fixture.mat`
+- `demo_data/hyperframe_simulated_fixture_metadata.json`
+
+## Capture Storyboard
+
+1. Setup frame
+ - Show the app purpose in one line: AWG/oscilloscope sweep measurement automation.
+ - Show the persistent label: `No hardware - simulated fixture`.
+ - Show the fixture source: `source=mock_fixture`.
+
+2. Sweep configuration frame
+ - Show a conservative sweep setup: 1 kHz to 1 MHz, log-spaced fixture points.
+ - Show correction mode as dual and trigger mode as triggered.
+ - Keep the wording clear that this is fixture replay, not connected instruments.
+
+3. Fixture replay frame
+ - Use `Load Demo Fixture` in the UI, or load `demo_data/hyperframe_simulated_fixture.mat`
+ through the normal load-measurement path.
+ - Show the plot after load.
+ - If Hyperframe supports animation, reveal points progressively from the fixture CSV.
+
+4. Gain and phase frame
+ - Show gain dB with a plausible roll-off and mild noise.
+ - Show phase shifting across frequency.
+ - Optional: show raw/reference/corrected fields from the CSV as a small data callout.
+
+5. Export/data frame
+ - Show MAT/CSV/TXT artifacts already present in `demo_data/`.
+ - Highlight that the same shape is accepted by the existing loader/export paths.
+
+6. Evidence frame
+ - Show: no-hardware tests, architecture boundaries, and live hardware validation boundary.
+ - Recommended copy:
+ `Core sweep math and persistence are testable without instruments; live hardware validation remains manual.`
+
+## Exact Manual Capture Steps
+
+1. Regenerate fixture data if needed:
+
+ ```bash
+ python scripts/generate_hyperframe_fixture.py
+ ```
+
+2. Start the desktop app only in the capture environment:
+
+ ```bash
+ python src/main.py
+ ```
+
+3. In the UI, choose `Load Demo Fixture`.
+
+ If that action is unavailable in an older build, choose the load-measurement action and open:
+
+ ```text
+ demo_data/hyperframe_simulated_fixture.mat
+ ```
+
+4. Capture the loaded plot and settings area.
+
+5. In Hyperframe, add a small persistent label:
+
+ ```text
+ No hardware - simulated fixture
+ ```
+
+6. Add one short data callout using `demo_data/hyperframe_simulated_fixture_metadata.json`:
+
+ ```text
+ source=mock_fixture; not live hardware validation
+ ```
+
+7. Add one short engineering callout:
+
+ ```text
+ Hardware side effects are isolated behind adapters; fixture replay exercises loader, plotting, and export shape.
+ ```
+
+## Do Not Claim
+
+- Do not say the fixture is a live AWG/oscilloscope run.
+- Do not say the demo validates connected hardware.
+- Do not add an app-level demo-mode banner before the first capture.
+- Do not edit production instrument adapters for this capture.
+
+## If Hyperframe Automation Is Later Added
+
+Prefer a separate capture helper that consumes the existing fixture files. Keep it outside production
+instrument code, and preserve the same visible no-hardware label in every exported clip.
diff --git a/docs/hyperframe_demo.md b/docs/hyperframe_demo.md
new file mode 100644
index 0000000..fa0ab38
--- /dev/null
+++ b/docs/hyperframe_demo.md
@@ -0,0 +1,51 @@
+# Hyperframe Demo Fixture
+
+This demo path is for application-material capture when AWG/oscilloscope hardware is not connected.
+
+Visible label to use in the Hyperframe capture:
+
+```text
+No hardware - simulated fixture
+```
+
+or:
+
+```text
+Simulated no-hardware demo fixture
+```
+
+## What To Show
+
+1. Configure a sweep in the desktop UI with conservative AWG/oscilloscope settings.
+2. Use the UI's `Load Demo Fixture` action, or manually load `demo_data/hyperframe_simulated_fixture.mat`.
+3. Show the plotted gain and phase response.
+4. Show the exported CSV/MAT/TXT fields, including raw, reference, and corrected values.
+5. Close with a short architecture/testing frame:
+ UI -> application use case -> domain DSP -> persistence/instrument ports.
+
+For the first fixture-replay capture, use the concrete storyboard in
+[`docs/hyperframe_capture_plan.md`](hyperframe_capture_plan.md).
+
+## Data Boundary
+
+The Hyperframe fixture is deterministic simulated data generated by `scripts/generate_hyperframe_fixture.py`.
+It is suitable for demonstrating the no-hardware UI, plotting, calibration-data shape, and export workflow.
+
+It is not live hardware validation. Live instrument behavior still depends on the connected AWG,
+oscilloscope, VISA/LAN access, DUT limits, and operator safety checks.
+
+## Fixture Files
+
+- `demo_data/hyperframe_simulated_fixture.mat`: loadable measurement fixture.
+- `demo_data/hyperframe_simulated_fixture.csv`: human-readable measurement rows.
+- `demo_data/hyperframe_simulated_fixture.txt`: tab-separated numeric export.
+- `demo_data/hyperframe_reference_fixture.mat`: reference calibration curve.
+- `demo_data/hyperframe_simulated_fixture_metadata.json`: source and validation-boundary metadata.
+
+## Regenerate
+
+```bash
+python scripts/generate_hyperframe_fixture.py
+```
+
+The generator uses a fixed seed so the fixture remains reproducible across machines.
diff --git a/docs/images/README.md b/docs/images/README.md
index 3105f54..ec87c0d 100644
--- a/docs/images/README.md
+++ b/docs/images/README.md
@@ -4,5 +4,33 @@ Expected portfolio screenshots:
- `main_ui.png`: the configured desktop app before a sweep.
- `sweep_result.png`: a completed or loaded sweep result. The current file is generated from `demo_data/Demo(2).mat`.
+- `auto-load-off-test-demo-capture.mp4`: real Tk operator console capture showing the no-hardware
+ Hyperframe fixture load flow.
+- `auto-load-off-test-demo-capture.png`: poster frame from that real UI capture.
+- `auto-load-off-test-point-replay-demo.mp4`: real Tk operator console capture showing the
+ deterministic no-hardware fixture replayed point by point.
+- `auto-load-off-test-point-replay-demo.png`: poster frame from the point-by-point replay capture.
Capture these from the real Tk desktop app. Do not replace them with generated mockups, because the value of this project is that it controls a real lab workflow.
+
+For Hyperframe application-material capture without instruments, use a visible label such as
+`No hardware - simulated fixture` when showing `demo_data/hyperframe_simulated_fixture.*`.
+This demonstrates the UI/data workflow but must not be described as live hardware validation.
+
+The reproducible local capture helper is:
+
+```bash
+PYTHONPATH=src python scripts/capture_operator_console_demo.py
+```
+
+It starts the real Tk app, loads the deterministic fixture through the UI/controller path, captures
+the Tk window with macOS `screencapture`, and writes the mp4/poster files above.
+
+For the point-by-point replay capture:
+
+```bash
+PYTHONPATH=src python scripts/capture_operator_console_point_replay.py
+```
+
+That helper keeps the replay inside the demo/capture boundary: it feeds partial fixture results to the
+existing Tk plot/view-model path and never calls production instrument adapters.
diff --git a/docs/images/auto-load-off-test-demo-capture.mp4 b/docs/images/auto-load-off-test-demo-capture.mp4
new file mode 100644
index 0000000..6dce8aa
Binary files /dev/null and b/docs/images/auto-load-off-test-demo-capture.mp4 differ
diff --git a/docs/images/auto-load-off-test-demo-capture.png b/docs/images/auto-load-off-test-demo-capture.png
new file mode 100644
index 0000000..b4f970f
Binary files /dev/null and b/docs/images/auto-load-off-test-demo-capture.png differ
diff --git a/docs/images/auto-load-off-test-point-replay-demo.mp4 b/docs/images/auto-load-off-test-point-replay-demo.mp4
new file mode 100644
index 0000000..00c52c1
Binary files /dev/null and b/docs/images/auto-load-off-test-point-replay-demo.mp4 differ
diff --git a/docs/images/auto-load-off-test-point-replay-demo.png b/docs/images/auto-load-off-test-point-replay-demo.png
new file mode 100644
index 0000000..33481da
Binary files /dev/null and b/docs/images/auto-load-off-test-point-replay-demo.png differ
diff --git a/scripts/capture_operator_console_demo.py b/scripts/capture_operator_console_demo.py
new file mode 100644
index 0000000..8700ba7
--- /dev/null
+++ b/scripts/capture_operator_console_demo.py
@@ -0,0 +1,217 @@
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+from typing import Callable
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+OUT_DIR = ROOT / "docs" / "images"
+POSTER = OUT_DIR / "auto-load-off-test-demo-capture.png"
+VIDEO = OUT_DIR / "auto-load-off-test-demo-capture.mp4"
+FRAME_RATE = 12
+
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from app.bootstrap import build_desktop_app # noqa: E402
+from app.runtime.paths import AppPaths # noqa: E402
+
+
+def main() -> None:
+ _require_tools()
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ app = build_desktop_app(paths=AppPaths.from_root(ROOT))
+ app.window.geometry("1366x768+40+60")
+ app.window.update()
+ window_id = _find_window_id(app.window.title())
+ print(f"window_id: {window_id}")
+ with tempfile.TemporaryDirectory(prefix="auto-load-off-test-capture-") as td:
+ session = CaptureSession(app=app, frame_dir=Path(td), window_id=window_id)
+ session.run()
+
+
+class CaptureSession:
+ def __init__(self, *, app, frame_dir: Path, window_id: int) -> None:
+ self.app = app
+ self.window = app.window
+ self.frame_dir = frame_dir
+ self.frame_index = 1
+ self.window_id = window_id
+
+ def run(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(command=self.app.controller.on_load_demo_fixture)
+ self.window.plot_widget.set_mode(self.window.vm.figure_mode.get())
+ self.app.controller.on_mag_phase_change()
+ self.window.after(1200, self._start)
+ self.window.mainloop()
+
+ def _start(self) -> None:
+ _raise_window(self.window)
+ self._capture_frames(14, self._press_demo_button)
+
+ def _press_demo_button(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(relief="sunken")
+ self._capture_frames(4, self._load_fixture)
+
+ def _load_fixture(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(relief="flat")
+ self.app.controller.on_load_demo_fixture()
+ _raise_window(self.window)
+ self._capture_frames(29, self._finish)
+
+ def _capture_frames(self, remaining: int, done: Callable[[], None]) -> None:
+ if remaining <= 0:
+ done()
+ return
+ _capture_window(self.window_id, self.frame_dir / f"frame_{self.frame_index:04d}.png")
+ self.frame_index += 1
+ self.window.after(int(1000 / FRAME_RATE), lambda: self._capture_frames(remaining - 1, done))
+
+ def _finish(self) -> None:
+ _capture_window(self.window_id, POSTER)
+ _write_video(self.frame_dir)
+
+ print(f"poster: {POSTER.relative_to(ROOT)}")
+ print(f"video: {VIDEO.relative_to(ROOT)}")
+ print(f"point_count: {self.window.vm.point_count_text.get()}")
+ print(f"source: {self.window.vm.data_source_text.get()}")
+ print(f"badge: {self.window.vm.fixture_badge_text.get()}")
+ print(f"validation: {self.window.vm.validation_receipt_text.get()}")
+ print(f"window: {self.window.winfo_geometry()}")
+ self.window.destroy()
+
+
+def _require_tools() -> None:
+ missing = [tool for tool in ("screencapture", "ffmpeg") if shutil.which(tool) is None]
+ if missing:
+ raise RuntimeError(f"Missing capture tool(s): {', '.join(missing)}")
+
+
+def _raise_window(window) -> None:
+ try:
+ subprocess.run(
+ [
+ "osascript",
+ "-e",
+ f'tell application "System Events" to set frontmost of first application process whose unix id is {os.getpid()} to true',
+ ],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except Exception:
+ pass
+ window.lift()
+ window.focus_force()
+ window.attributes("-topmost", True)
+ window.update()
+ time.sleep(0.2)
+ window.update()
+
+
+def _find_window_id(title: str) -> int:
+ script = _window_list_script_path()
+ last_stdout = ""
+ last_stderr = ""
+ for _attempt in range(12):
+ result = subprocess.run(
+ ["swift", str(script), title],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ last_stdout = result.stdout
+ last_stderr = result.stderr
+ for line in result.stdout.splitlines():
+ line = line.strip()
+ if line.isdigit():
+ return int(line)
+ time.sleep(0.25)
+ raise RuntimeError(
+ f"Could not find Tk window id for title: {title}; "
+ f"stdout={last_stdout!r}; stderr={last_stderr!r}"
+ )
+
+
+def _window_list_script_path() -> Path:
+ script = Path(tempfile.gettempdir()) / "auto_load_off_test_window_id.swift"
+ script.write_text(
+ """
+import CoreGraphics
+import Darwin
+
+let targetTitle = CommandLine.arguments.dropFirst().joined(separator: " ")
+let options = CGWindowListOption(arrayLiteral: .optionAll)
+var fallbackWindow: Int?
+if let windows = CGWindowListCopyWindowInfo(options, CGWindowID(0)) as? [[String: Any]] {
+ for window in windows {
+ let owner = window[kCGWindowOwnerName as String] as? String ?? ""
+ let name = window[kCGWindowName as String] as? String ?? ""
+ let number = window[kCGWindowNumber as String] as? Int ?? 0
+ let layer = window[kCGWindowLayer as String] as? Int ?? 0
+ let isPython = owner.lowercased().contains("python")
+ if isPython && name == targetTitle && layer == 0 {
+ print(number)
+ exit(0)
+ }
+ if isPython && name.contains("Auto-Load-off-Test") && layer == 0 {
+ print(number)
+ exit(0)
+ }
+ if isPython && layer == 0 && fallbackWindow == nil {
+ fallbackWindow = number
+ }
+ }
+}
+if let fallback = fallbackWindow {
+ print(fallback)
+ exit(0)
+}
+exit(1)
+""".strip()
+ )
+ return script
+
+
+def _capture_window(window_id: int | None, output_path: Path) -> None:
+ if window_id is None:
+ raise RuntimeError("Capture window id is not set")
+ subprocess.run(
+ ["screencapture", "-x", "-l", str(window_id), str(output_path)],
+ check=True,
+ )
+
+
+def _write_video(frame_dir: Path) -> None:
+ subprocess.run(
+ [
+ "ffmpeg",
+ "-y",
+ "-framerate",
+ str(FRAME_RATE),
+ "-i",
+ str(frame_dir / "frame_%04d.png"),
+ "-vf",
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ str(VIDEO),
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/capture_operator_console_point_replay.py b/scripts/capture_operator_console_point_replay.py
new file mode 100644
index 0000000..3fe6714
--- /dev/null
+++ b/scripts/capture_operator_console_point_replay.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+from typing import Callable
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+OUT_DIR = ROOT / "docs" / "images"
+FIXTURE = ROOT / "demo_data" / "hyperframe_simulated_fixture.mat"
+POSTER = OUT_DIR / "auto-load-off-test-point-replay-demo.png"
+VIDEO = OUT_DIR / "auto-load-off-test-point-replay-demo.mp4"
+FRAME_RATE = 12
+DEMO_SIZE = "1366x768+40+60"
+
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from app.bootstrap import build_desktop_app # noqa: E402
+from app.domain.models import SweepResult # noqa: E402
+from app.runtime.paths import AppPaths # noqa: E402
+
+from capture_operator_console_demo import ( # noqa: E402
+ _capture_window,
+ _find_window_id,
+ _raise_window,
+ _require_tools,
+)
+
+
+def main() -> None:
+ _require_tools()
+ _require_file(FIXTURE)
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ app = build_desktop_app(paths=AppPaths.from_root(ROOT))
+ app.window.geometry(DEMO_SIZE)
+ app.window.update()
+ window_id = _find_window_id(app.window.title())
+ print(f"window_id: {window_id}")
+
+ loaded = app.controller.load_measurement_use_case.execute(str(FIXTURE))
+ with tempfile.TemporaryDirectory(prefix="auto-load-off-test-point-replay-") as td:
+ session = PointReplayCaptureSession(
+ app=app,
+ frame_dir=Path(td),
+ window_id=window_id,
+ full_result=loaded.result,
+ )
+ session.run()
+
+
+class PointReplayCaptureSession:
+ def __init__(self, *, app, frame_dir: Path, window_id: int, full_result: SweepResult) -> None:
+ self.app = app
+ self.window = app.window
+ self.vm = app.window.vm
+ self.frame_dir = frame_dir
+ self.window_id = window_id
+ self.full_result = full_result
+ self.total_points = len(full_result.points)
+ self.frame_index = 1
+ self.replay_index = 0
+
+ def run(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(command=self._start_replay)
+ self.vm.magnitude_phase_mode.set("magnitude_phase")
+ self.window.plot_widget.set_mode(self.vm.figure_mode.get())
+ self.app.controller.on_mag_phase_change()
+ self._set_initial_state()
+ self.window.after(1200, self._capture_start)
+ self.window.mainloop()
+
+ def _capture_start(self) -> None:
+ _raise_window(self.window)
+ self._capture_frames(12, self._press_demo_button)
+
+ def _press_demo_button(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(relief="sunken")
+ self._capture_frames(4, self._start_replay)
+
+ def _start_replay(self) -> None:
+ self.window.run_panel.btn_load_demo_fixture.configure(relief="flat")
+ self.replay_index = 0
+ self._set_replay_state(0)
+ self._capture_replay_step()
+
+ def _capture_replay_step(self) -> None:
+ if self.replay_index >= self.total_points:
+ self._capture_frames(24, self._finish)
+ return
+
+ self.replay_index += 1
+ self._set_replay_state(self.replay_index)
+ _capture_window(self.window_id, self.frame_dir / f"frame_{self.frame_index:04d}.png")
+ self.frame_index += 1
+ self.window.after(int(1000 / FRAME_RATE), self._capture_replay_step)
+
+ def _capture_frames(self, remaining: int, done: Callable[[], None]) -> None:
+ if remaining <= 0:
+ done()
+ return
+ _capture_window(self.window_id, self.frame_dir / f"frame_{self.frame_index:04d}.png")
+ self.frame_index += 1
+ self.window.after(int(1000 / FRAME_RATE), lambda: self._capture_frames(remaining - 1, done))
+
+ def _set_initial_state(self) -> None:
+ self.app.controller._ui_handler.set_result(SweepResult(), refresh_plot=True)
+ self.vm.data_source_text.set("Live instrument path")
+ self.vm.fixture_badge_text.set("")
+ self.vm.validation_receipt_text.set("Live run requires operator hardware checks")
+ self.vm.export_receipt_text.set("No export yet")
+ self.vm.run_state_text.set("Idle")
+ self.vm.progress_text.set("0 / 0")
+ self.vm.point_count_text.set("0 points")
+ self.vm.latest_frequency_text.set("-")
+ self.vm.status_text.set("Ready")
+
+ def _set_replay_state(self, point_count: int) -> None:
+ partial = SweepResult(
+ points=list(self.full_result.points[:point_count]),
+ meta=dict(self.full_result.meta),
+ )
+ self.app.controller._ui_handler.set_result(partial, refresh_plot=True)
+ self.vm.data_source_text.set(f"Fixture replay · {FIXTURE.name}")
+ self.vm.fixture_badge_text.set("No hardware - simulated fixture")
+ self.vm.validation_receipt_text.set(
+ "Point-by-point simulated fixture replay; not live hardware validation"
+ )
+ self.vm.export_receipt_text.set("Replaying deterministic fixture points; no hardware connected")
+ self.vm.run_state_text.set("Replaying fixture" if point_count < self.total_points else "Fixture ready")
+ self.vm.progress_text.set(f"{point_count} / {self.total_points}")
+ self.vm.point_count_text.set(f"{point_count} / {self.total_points} points")
+ if point_count:
+ latest = self.full_result.points[point_count - 1]
+ self.vm.latest_frequency_text.set(_format_frequency(latest.freq_hz))
+ else:
+ self.vm.latest_frequency_text.set("-")
+ self.vm.status_text.set(f"Fixture replay point {point_count}/{self.total_points} (no hardware)")
+
+ def _finish(self) -> None:
+ _capture_window(self.window_id, POSTER)
+ _write_video(self.frame_dir)
+ print(f"poster: {POSTER.relative_to(ROOT)}")
+ print(f"video: {VIDEO.relative_to(ROOT)}")
+ print(f"point_count: {self.vm.point_count_text.get()}")
+ print(f"source: {self.vm.data_source_text.get()}")
+ print(f"badge: {self.vm.fixture_badge_text.get()}")
+ print(f"validation: {self.vm.validation_receipt_text.get()}")
+ print(f"window: {self.window.winfo_geometry()}")
+ self.window.destroy()
+
+
+def _write_video(frame_dir: Path) -> None:
+ subprocess.run(
+ [
+ "ffmpeg",
+ "-y",
+ "-framerate",
+ str(FRAME_RATE),
+ "-i",
+ str(frame_dir / "frame_%04d.png"),
+ "-vf",
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ str(VIDEO),
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+
+def _require_file(path: Path) -> None:
+ if not path.exists():
+ raise RuntimeError(f"Required file not found: {path}")
+
+
+def _format_frequency(freq_hz: float) -> str:
+ if abs(freq_hz) >= 1_000_000:
+ return f"{freq_hz / 1_000_000:.3g} MHz"
+ if abs(freq_hz) >= 1_000:
+ return f"{freq_hz / 1_000:.3g} kHz"
+ return f"{freq_hz:.3g} Hz"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_hyperframe_fixture.py b/scripts/generate_hyperframe_fixture.py
new file mode 100644
index 0000000..b1dbb21
--- /dev/null
+++ b/scripts/generate_hyperframe_fixture.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SRC = ROOT / "src"
+if str(SRC) not in sys.path:
+ sys.path.insert(0, str(SRC))
+
+from app.demo.hyperframe_fixture import write_fixture # noqa: E402
+
+
+def main() -> None:
+ paths = write_fixture(ROOT / "demo_data")
+ for name, path in paths.items():
+ print(f"{name}: {path.relative_to(ROOT)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/app/demo/__init__.py b/src/app/demo/__init__.py
new file mode 100644
index 0000000..648603c
--- /dev/null
+++ b/src/app/demo/__init__.py
@@ -0,0 +1 @@
+"""Demo fixture helpers kept separate from live instrument adapters."""
diff --git a/src/app/demo/hyperframe_fixture.py b/src/app/demo/hyperframe_fixture.py
new file mode 100644
index 0000000..b13fdf8
--- /dev/null
+++ b/src/app/demo/hyperframe_fixture.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+import csv
+import json
+from pathlib import Path
+
+import numpy as np
+from scipy.io import savemat
+
+from app.domain.enums import (
+ ConnectionMode,
+ CorrectionMode,
+ CouplingMode,
+ ImpedanceMode,
+ MagnitudePhaseMode,
+ TriggerMode,
+)
+from app.domain.exporters import settings_to_metadata
+from app.domain.models import (
+ AppSettings,
+ AwgSettings,
+ ChannelSelection,
+ InstrumentEndpoint,
+ InstrumentSetup,
+ OscSettings,
+ RunMode,
+ SweepPoint,
+ SweepResult,
+ SweepSpec,
+)
+
+
+SOURCE = "mock_fixture"
+DEMO_LABEL = "Simulated no-hardware demo fixture"
+SEED = 20260601
+POINT_COUNT = 72
+START_HZ = 1_000.0
+STOP_HZ = 1_000_000.0
+
+
+def build_fixture_settings() -> AppSettings:
+ return AppSettings(
+ schema_version=1,
+ freq_unit="Hz",
+ sweep=SweepSpec(
+ start_hz=START_HZ,
+ stop_hz=STOP_HZ,
+ step_hz=None,
+ step_count=POINT_COUNT,
+ is_log=True,
+ ),
+ run_mode=RunMode(
+ correction_mode=CorrectionMode.DUAL,
+ trigger_mode=TriggerMode.TRIGGERED,
+ auto_range=True,
+ auto_reset=True,
+ ),
+ setup=InstrumentSetup(
+ awg=InstrumentEndpoint(
+ model="DSG4102",
+ connect_mode=ConnectionMode.AUTO,
+ visa_address="SIMULATED_AWG",
+ ip_address="0.0.0.0",
+ ),
+ osc=InstrumentEndpoint(
+ model="MDO34",
+ connect_mode=ConnectionMode.AUTO,
+ visa_address="SIMULATED_OSC",
+ ip_address="0.0.0.0",
+ ),
+ channels=ChannelSelection(awg_ch=1, osc_test_ch=1, osc_ref_ch=2, osc_trig_ch=2),
+ awg_settings=AwgSettings(amplitude_vpp=1.0, impedance=ImpedanceMode.R50),
+ osc_settings=OscSettings(
+ full_scale_v=1.0,
+ offset_v=0.0,
+ points=10_000,
+ impedance=ImpedanceMode.R50,
+ coupling=CouplingMode.DC,
+ ),
+ ),
+ magnitude_phase_mode=MagnitudePhaseMode.MAG_AND_PHASE,
+ auto_save_data=False,
+ )
+
+
+def build_fixture_curves(point_count: int = POINT_COUNT, seed: int = SEED) -> dict[str, np.ndarray]:
+ rng = np.random.default_rng(seed)
+ freq_hz = np.logspace(np.log10(START_HZ), np.log10(STOP_HZ), point_count)
+ log_pos = (np.log10(freq_hz) - np.log10(START_HZ)) / (np.log10(STOP_HZ) - np.log10(START_HZ))
+
+ reference_gain_db = (
+ -0.22
+ - 0.42 * log_pos
+ + 0.09 * np.sin(2.0 * np.pi * log_pos * 2.3)
+ + rng.normal(0.0, 0.018, point_count)
+ )
+ reference_phase_deg = (
+ -1.2
+ - 7.5 * log_pos
+ + 0.35 * np.sin(2.0 * np.pi * log_pos * 1.6)
+ + rng.normal(0.0, 0.12, point_count)
+ )
+
+ cutoff_hz = 115_000.0
+ low_pass_gain_db = -10.0 * np.log10(1.0 + (freq_hz / cutoff_hz) ** 2)
+ resonance_bump_db = 1.35 * np.exp(-0.5 * (np.log10(freq_hz / 28_000.0) / 0.16) ** 2)
+ ripple_db = 0.16 * np.sin(2.0 * np.pi * log_pos * 4.7 + 0.35)
+ corrected_gain_db = (
+ 0.72
+ + low_pass_gain_db
+ + resonance_bump_db
+ + ripple_db
+ + rng.normal(0.0, 0.045, point_count)
+ )
+ corrected_phase_deg = (
+ -np.degrees(np.arctan(freq_hz / cutoff_hz))
+ + 5.0 * np.exp(-0.5 * (np.log10(freq_hz / 34_000.0) / 0.2) ** 2)
+ + 0.8 * np.sin(2.0 * np.pi * log_pos * 2.1)
+ + rng.normal(0.0, 0.32, point_count)
+ )
+
+ raw_gain_db = corrected_gain_db + reference_gain_db
+ raw_phase_deg = corrected_phase_deg + reference_phase_deg
+ gain_linear = 10.0 ** (corrected_gain_db / 20.0)
+ gain_complex = gain_linear * np.exp(1j * np.deg2rad(corrected_phase_deg))
+
+ return {
+ "freq_hz": freq_hz,
+ "gain_linear": gain_linear,
+ "gain_db": corrected_gain_db,
+ "phase_deg": corrected_phase_deg,
+ "gain_db_raw": raw_gain_db,
+ "phase_deg_raw": raw_phase_deg,
+ "gain_db_reference": reference_gain_db,
+ "phase_deg_reference": reference_phase_deg,
+ "gain_db_corrected": corrected_gain_db,
+ "phase_deg_corrected": corrected_phase_deg,
+ "gain_complex_real": gain_complex.real,
+ "gain_complex_imag": gain_complex.imag,
+ }
+
+
+def build_fixture_result(curves: dict[str, np.ndarray] | None = None) -> SweepResult:
+ data = curves or build_fixture_curves()
+ points = []
+ for freq_hz, gain_linear, gain_db, phase_deg, real, imag in zip(
+ data["freq_hz"],
+ data["gain_linear"],
+ data["gain_db"],
+ data["phase_deg"],
+ data["gain_complex_real"],
+ data["gain_complex_imag"],
+ strict=True,
+ ):
+ points.append(
+ SweepPoint(
+ freq_hz=float(freq_hz),
+ gain_linear=float(gain_linear),
+ gain_db=float(gain_db),
+ phase_deg=float(phase_deg),
+ gain_complex=complex(float(real), float(imag)),
+ )
+ )
+ return SweepResult(
+ points=points,
+ meta={
+ "source": SOURCE,
+ "demo_label": DEMO_LABEL,
+ "point_count": len(points),
+ "sweep_start_hz": START_HZ,
+ "sweep_stop_hz": STOP_HZ,
+ },
+ )
+
+
+def build_measurement_payload(
+ curves: dict[str, np.ndarray] | None = None,
+ settings: AppSettings | None = None,
+) -> dict[str, object]:
+ data = curves or build_fixture_curves()
+ app_settings = settings or build_fixture_settings()
+ metadata = settings_to_metadata(app_settings)
+ metadata["demo_fixture"] = {
+ "source": SOURCE,
+ "label": DEMO_LABEL,
+ "point_count": int(len(data["freq_hz"])),
+ "sweep_start_hz": START_HZ,
+ "sweep_stop_hz": STOP_HZ,
+ "validation_boundary": "No hardware - simulated fixture; not live hardware validation.",
+ }
+
+ payload: dict[str, object] = {
+ "schema_version": app_settings.schema_version,
+ "metadata_json": json.dumps(metadata, ensure_ascii=True, sort_keys=True),
+ "source": SOURCE,
+ "demo_label": DEMO_LABEL,
+ "point_count": int(len(data["freq_hz"])),
+ "sweep_start_hz": START_HZ,
+ "sweep_stop_hz": STOP_HZ,
+ "correction_mode": app_settings.run_mode.correction_mode.value,
+ "trigger_mode": app_settings.run_mode.trigger_mode.value,
+ "awg_impedance": app_settings.setup.awg_settings.impedance.value,
+ "osc_impedance": app_settings.setup.osc_settings.impedance.value,
+ "osc_coupling": app_settings.setup.osc_settings.coupling.value,
+ }
+ payload.update(data)
+ return payload
+
+
+def build_reference_payload(curves: dict[str, np.ndarray] | None = None) -> dict[str, object]:
+ data = curves or build_fixture_curves()
+ return {
+ "source": SOURCE,
+ "demo_label": DEMO_LABEL,
+ "fixture_role": "reference_calibration",
+ "freq_hz": data["freq_hz"],
+ "gain_db": data["gain_db_reference"],
+ "phase_deg": data["phase_deg_reference"],
+ }
+
+
+def write_fixture(output_dir: Path) -> dict[str, Path]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ curves = build_fixture_curves()
+ measurement_payload = build_measurement_payload(curves)
+ reference_payload = build_reference_payload(curves)
+
+ measurement_base = output_dir / "hyperframe_simulated_fixture"
+ reference_mat = output_dir / "hyperframe_reference_fixture.mat"
+ metadata_json = output_dir / "hyperframe_simulated_fixture_metadata.json"
+
+ mat_path = measurement_base.with_suffix(".mat")
+ csv_path = measurement_base.with_suffix(".csv")
+ txt_path = measurement_base.with_suffix(".txt")
+
+ _write_mat(mat_path, measurement_payload)
+ _write_mat(reference_mat, reference_payload)
+ _write_csv(csv_path, measurement_payload)
+ _write_txt(txt_path, measurement_payload)
+
+ metadata_json.write_text(
+ json.dumps(
+ {
+ "source": SOURCE,
+ "demo_label": DEMO_LABEL,
+ "validation_boundary": "No hardware - simulated fixture; not live hardware validation.",
+ "point_count": int(measurement_payload["point_count"]),
+ "sweep_start_hz": START_HZ,
+ "sweep_stop_hz": STOP_HZ,
+ "correction_mode": measurement_payload["correction_mode"],
+ "trigger_mode": measurement_payload["trigger_mode"],
+ "generated_from": "src/app/demo/hyperframe_fixture.py",
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ return {
+ "measurement_mat": mat_path,
+ "measurement_csv": csv_path,
+ "measurement_txt": txt_path,
+ "reference_mat": reference_mat,
+ "metadata_json": metadata_json,
+ }
+
+
+def _write_mat(path: Path, payload: dict[str, object]) -> None:
+ savemat(path, payload)
+ header = (
+ b"MATLAB 5.0 MAT-file, Auto-Load-off-Test simulated no-hardware fixture, "
+ b"source=mock_fixture"
+ )
+ if len(header) > 116:
+ raise ValueError("MAT header is too long")
+ with path.open("r+b") as fh:
+ fh.write(header.ljust(116, b" "))
+
+
+def _write_csv(path: Path, payload: dict[str, object]) -> None:
+ numeric_fields = [
+ "freq_hz",
+ "gain_linear",
+ "gain_db",
+ "phase_deg",
+ "gain_db_raw",
+ "phase_deg_raw",
+ "gain_db_reference",
+ "phase_deg_reference",
+ "gain_db_corrected",
+ "phase_deg_corrected",
+ ]
+ fields = [
+ "source",
+ "demo_label",
+ "freq_hz",
+ "gain_linear",
+ "gain_db",
+ "phase_deg",
+ "gain_db_raw",
+ "phase_deg_raw",
+ "gain_db_reference",
+ "phase_deg_reference",
+ "gain_db_corrected",
+ "phase_deg_corrected",
+ ]
+ count = int(payload["point_count"])
+ with path.open("w", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=fields, lineterminator="\n")
+ writer.writeheader()
+ for idx in range(count):
+ row: dict[str, object] = {
+ "source": SOURCE,
+ "demo_label": DEMO_LABEL,
+ }
+ for field in numeric_fields:
+ row[field] = float(np.asarray(payload[field]).squeeze()[idx])
+ writer.writerow(row)
+
+
+def _write_txt(path: Path, payload: dict[str, object]) -> None:
+ fields = [
+ "freq_hz",
+ "gain_linear",
+ "gain_db",
+ "phase_deg",
+ "gain_db_raw",
+ "phase_deg_raw",
+ "gain_db_reference",
+ "phase_deg_reference",
+ "gain_db_corrected",
+ "phase_deg_corrected",
+ ]
+ rows = np.column_stack([np.asarray(payload[field], dtype=float).squeeze() for field in fields])
+ header = (
+ f"source={SOURCE}; label={DEMO_LABEL}; "
+ "boundary=No hardware - simulated fixture; not live hardware validation.\n"
+ + "\t".join(fields)
+ )
+ np.savetxt(path, rows, delimiter="\t", header=header, comments="")
diff --git a/src/app/presentation/tk/app_window.py b/src/app/presentation/tk/app_window.py
index 3a46c11..34557f6 100644
--- a/src/app/presentation/tk/app_window.py
+++ b/src/app/presentation/tk/app_window.py
@@ -1,9 +1,11 @@
from __future__ import annotations
import tkinter as tk
+from tkinter import ttk
from app.presentation.tk.control_panel import ControlPanel
from app.presentation.tk.plot_widget import PlotWidget
+from app.presentation.tk.run_panel import RunPanel
from app.presentation.tk.view_model import ViewModel
@@ -11,43 +13,68 @@ class AppWindow(tk.Tk):
def __init__(self, vm: ViewModel | None = None) -> None:
super().__init__()
self.vm = vm or ViewModel(self)
- self.title("LoadoffTest - Decoupled")
- self.geometry("1400x900")
+ self.title("Auto-Load-off-Test Operator Console")
+ self.geometry("1440x810")
+ self.minsize(1280, 760)
+ self.configure(bg="#f4f6f8")
self.protocol("WM_DELETE_WINDOW", self.on_close)
+ self._configure_style()
self._on_close = None
- container = tk.Frame(self)
- container.pack(fill=tk.BOTH, expand=True)
+ container = tk.Frame(self, bg="#f4f6f8")
+ container.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
+ container.grid_columnconfigure(0, weight=0, minsize=295)
+ container.grid_columnconfigure(1, weight=1, minsize=640)
+ container.grid_columnconfigure(2, weight=0, minsize=340)
+ container.grid_rowconfigure(0, weight=1)
- left = tk.Frame(container)
- left.pack(side=tk.LEFT, fill=tk.Y, padx=8, pady=8)
+ left = tk.Frame(container, bg="#f4f6f8", width=300)
+ left.grid(row=0, column=0, sticky="nsew", padx=(0, 12))
+ left.grid_propagate(False)
+ left.grid_rowconfigure(0, weight=1)
+ left.grid_columnconfigure(0, weight=1)
- right = tk.Frame(container)
- right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=8, pady=8)
+ center = tk.Frame(container, bg="#f4f6f8")
+ center.grid(row=0, column=1, sticky="nsew", padx=(0, 12))
+ center.grid_rowconfigure(0, weight=1)
+ center.grid_columnconfigure(0, weight=1)
- self.control_panel = ControlPanel(left, self.vm)
- self.control_panel.pack(fill=tk.Y)
- self._alias_control_widgets()
+ right = tk.Frame(container, bg="#f4f6f8")
+ right.grid(row=0, column=2, sticky="nsew")
+
+ self.control_panel = self._build_sidebar(left)
- self.plot_widget = PlotWidget(right)
- self.plot_widget.frame.pack(fill=tk.BOTH, expand=True)
+ self.plot_widget = PlotWidget(center, self.vm)
+ self.plot_widget.frame.grid(row=0, column=0, sticky="nsew")
- status_bar = tk.Frame(self)
+ self.run_panel = RunPanel(right, self.vm)
+ self.run_panel.pack(fill=tk.BOTH, expand=True)
+ self._alias_control_widgets()
+
+ status_bar = tk.Frame(self, bg="#e5eaf0")
status_bar.pack(fill=tk.X, side=tk.BOTTOM)
- self.lb_status = tk.Label(status_bar, textvariable=self.vm.status_text, anchor="w")
+ self.lb_status = tk.Label(
+ status_bar,
+ textvariable=self.vm.status_text,
+ anchor="w",
+ bg="#e5eaf0",
+ fg="#1f2937",
+ padx=10,
+ pady=4,
+ )
self.lb_status.pack(side=tk.LEFT, fill=tk.X, expand=True)
- self.canvas_awg = tk.Canvas(status_bar, width=14, height=14)
+ self.canvas_awg = tk.Canvas(status_bar, width=14, height=14, bg="#e5eaf0", highlightthickness=0)
self.canvas_awg.pack(side=tk.RIGHT, padx=6)
self.awg_light = self.canvas_awg.create_oval(2, 2, 12, 12, fill="red")
- tk.Label(status_bar, text="AWG").pack(side=tk.RIGHT)
+ tk.Label(status_bar, text="AWG", bg="#e5eaf0", fg="#1f2937").pack(side=tk.RIGHT)
- self.canvas_osc = tk.Canvas(status_bar, width=14, height=14)
+ self.canvas_osc = tk.Canvas(status_bar, width=14, height=14, bg="#e5eaf0", highlightthickness=0)
self.canvas_osc.pack(side=tk.RIGHT, padx=6)
self.osc_light = self.canvas_osc.create_oval(2, 2, 12, 12, fill="red")
- tk.Label(status_bar, text="OSC").pack(side=tk.RIGHT)
+ tk.Label(status_bar, text="OSC", bg="#e5eaf0", fg="#1f2937").pack(side=tk.RIGHT)
def bind_actions(
self,
@@ -56,6 +83,7 @@ def bind_actions(
on_stop,
on_save_data,
on_load_data,
+ on_load_demo_fixture,
on_load_ref,
on_save_settings,
on_load_settings,
@@ -64,13 +92,20 @@ def bind_actions(
on_mag_phase_change,
) -> None:
self.control_panel.bind_actions(
+ on_save_settings=on_save_settings,
+ on_load_settings=on_load_settings,
+ )
+ self.run_panel.bind_actions(
on_start=on_start,
on_stop=on_stop,
on_save_data=on_save_data,
on_load_data=on_load_data,
+ on_load_demo_fixture=on_load_demo_fixture,
on_load_ref=on_load_ref,
on_save_settings=on_save_settings,
on_load_settings=on_load_settings,
+ )
+ self.plot_widget.bind_controls(
on_figure_change=on_figure_change,
on_mag_phase_change=on_mag_phase_change,
)
@@ -79,6 +114,8 @@ def bind_actions(
def set_connection_status(self, awg_connected: bool, osc_connected: bool) -> None:
self.canvas_awg.itemconfig(self.awg_light, fill="green" if awg_connected else "red")
self.canvas_osc.itemconfig(self.osc_light, fill="green" if osc_connected else "red")
+ self.vm.awg_connection_text.set("AWG online" if awg_connected else "AWG offline")
+ self.vm.osc_connection_text.set("OSC online" if osc_connected else "OSC offline")
def on_close(self) -> None:
if self._on_close is not None:
@@ -87,12 +124,52 @@ def on_close(self) -> None:
self.destroy()
def _alias_control_widgets(self) -> None:
- self.btn_start = self.control_panel.btn_start
- self.btn_stop = self.control_panel.btn_stop
- self.btn_save_data = self.control_panel.btn_save_data
- self.btn_load_data = self.control_panel.btn_load_data
- self.btn_load_ref = self.control_panel.btn_load_ref
+ self.btn_start = self.run_panel.btn_start
+ self.btn_stop = self.run_panel.btn_stop
+ self.btn_save_data = self.run_panel.btn_save_data
+ self.btn_load_data = self.run_panel.btn_load_data
+ self.btn_load_demo_fixture = self.run_panel.btn_load_demo_fixture
+ self.btn_load_ref = self.run_panel.btn_load_ref
self.btn_save_settings = self.control_panel.btn_save_settings
self.btn_load_settings = self.control_panel.btn_load_settings
- self.cmb_figure = self.control_panel.cmb_figure
- self.cmb_mag_phase = self.control_panel.cmb_mag_phase
+ self.cmb_figure = self.plot_widget.cmb_figure
+ self.cmb_mag_phase = self.plot_widget.cmb_mag_phase
+
+ def _build_sidebar(self, parent: tk.Frame) -> ControlPanel:
+ canvas = tk.Canvas(parent, bg="#f4f6f8", highlightthickness=0, width=300)
+ scrollbar = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=canvas.yview)
+ content = tk.Frame(canvas, bg="#f4f6f8")
+ window_id = canvas.create_window((0, 0), window=content, anchor="nw")
+
+ canvas.configure(yscrollcommand=scrollbar.set)
+ canvas.grid(row=0, column=0, sticky="nsew")
+ scrollbar.grid(row=0, column=1, sticky="ns")
+
+ def resize_content(_event=None) -> None:
+ canvas.configure(scrollregion=canvas.bbox("all"))
+ canvas.itemconfigure(window_id, width=canvas.winfo_width())
+
+ content.bind("", resize_content)
+ canvas.bind("", resize_content)
+
+ panel = ControlPanel(content, self.vm)
+ panel.pack(fill=tk.BOTH, expand=True)
+ return panel
+
+ def _configure_style(self) -> None:
+ style = ttk.Style(self)
+ try:
+ style.theme_use("clam")
+ except tk.TclError:
+ pass
+ style.configure(".", font=("TkDefaultFont", 10))
+ style.configure("TLabelframe", background="#ffffff", bordercolor="#d9e0e8")
+ style.configure(
+ "TLabelframe.Label",
+ background="#ffffff",
+ foreground="#1f2937",
+ font=("TkDefaultFont", 10, "bold"),
+ )
+ style.configure("TFrame", background="#ffffff")
+ style.configure("TButton", padding=(8, 5))
+ style.configure("TCombobox", padding=2)
diff --git a/src/app/presentation/tk/control_panel.py b/src/app/presentation/tk/control_panel.py
index 510ed55..9aa5e28 100644
--- a/src/app/presentation/tk/control_panel.py
+++ b/src/app/presentation/tk/control_panel.py
@@ -7,216 +7,190 @@
from app.shared.mapping import Mapping
+PANEL_BG = "#f4f6f8"
+CARD_BG = "#ffffff"
+TEXT = "#1f2937"
+MUTED = "#64748b"
+GREEN = "#15803d"
+AMBER = "#b45309"
+RED = "#b91c1c"
+
+
class ControlPanel(tk.Frame):
def __init__(self, parent: tk.Misc, vm: ViewModel) -> None:
- super().__init__(parent)
+ super().__init__(parent, bg=PANEL_BG)
self._vm = vm
+ self._trace_ids: list[tuple[tk.StringVar, str]] = []
self._build()
def bind_actions(
self,
*,
- on_start,
- on_stop,
- on_save_data,
- on_load_data,
- on_load_ref,
on_save_settings,
on_load_settings,
- on_figure_change,
- on_mag_phase_change,
) -> None:
- self.btn_start.configure(command=on_start)
- self.btn_stop.configure(command=on_stop)
- self.btn_save_data.configure(command=on_save_data)
- self.btn_load_data.configure(command=on_load_data)
- self.btn_load_ref.configure(command=on_load_ref)
self.btn_save_settings.configure(command=on_save_settings)
self.btn_load_settings.configure(command=on_load_settings)
- self.cmb_figure.bind("<>", lambda _e: on_figure_change())
- self.cmb_mag_phase.bind("<>", lambda _e: on_mag_phase_change())
-
def _build(self) -> None:
- row = 0
-
- def add_label(text: str, r: int, c: int = 0) -> None:
- tk.Label(self, text=text).grid(row=r, column=c, sticky="w", padx=3, pady=2)
-
- add_label("AWG model", row)
- ttk.Combobox(self, textvariable=self._vm.awg_model, values=Mapping.values_awg, width=12).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- add_label("OSC model", row)
- ttk.Combobox(self, textvariable=self._vm.osc_model, values=Mapping.values_osc, width=12).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- add_label("AWG conn", row)
- ttk.Combobox(self, textvariable=self._vm.awg_connect_mode, values=["auto", "lan"], width=12).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- add_label("AWG VISA", row)
- tk.Entry(self, textvariable=self._vm.awg_visa, width=28).grid(row=row, column=1, sticky="ew")
- row += 1
-
- add_label("AWG IP", row)
- tk.Entry(self, textvariable=self._vm.awg_ip, width=28).grid(row=row, column=1, sticky="ew")
- row += 1
-
- add_label("OSC conn", row)
- ttk.Combobox(self, textvariable=self._vm.osc_connect_mode, values=["auto", "lan"], width=12).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- add_label("OSC VISA", row)
- tk.Entry(self, textvariable=self._vm.osc_visa, width=28).grid(row=row, column=1, sticky="ew")
- row += 1
-
- add_label("OSC IP", row)
- tk.Entry(self, textvariable=self._vm.osc_ip, width=28).grid(row=row, column=1, sticky="ew")
- row += 1
-
- self._separator(row)
- row += 1
-
- add_label("Freq unit", row)
- ttk.Combobox(self, textvariable=self._vm.freq_unit, values=Mapping.values_freq_unit, width=8).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- for label, variable in (
- ("Start", self._vm.start_freq),
- ("Stop", self._vm.stop_freq),
- ("Step", self._vm.step_freq),
- ("Step count", self._vm.step_count),
- ):
- add_label(label, row)
- tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew")
- row += 1
-
- tk.Checkbutton(self, text="Log sweep", variable=self._vm.is_log).grid(
- row=row, column=0, columnspan=2, sticky="w"
+ self.grid_columnconfigure(0, weight=1)
+ tk.Label(
+ self,
+ text="Setup",
+ bg=PANEL_BG,
+ fg=TEXT,
+ font=("TkDefaultFont", 14, "bold"),
+ anchor="w",
+ ).grid(row=0, column=0, sticky="ew", pady=(0, 10))
+
+ self._build_instruments(1)
+ self._build_sweep(2)
+ self._build_channels(3)
+ self._build_settings_actions(4)
+
+ def _section(self, title: str, row: int) -> ttk.LabelFrame:
+ section = ttk.LabelFrame(self, text=title, padding=(8, 6))
+ section.grid(row=row, column=0, sticky="ew", pady=(0, 10))
+ section.grid_columnconfigure(1, weight=1)
+ return section
+
+ def _build_instruments(self, row: int) -> None:
+ section = self._section("Instruments", row)
+ self._combo(section, "AWG model", self._vm.awg_model, Mapping.values_awg, 0)
+ self._combo(section, "OSC model", self._vm.osc_model, Mapping.values_osc, 1)
+ self._combo(section, "AWG conn", self._vm.awg_connect_mode, ["auto", "lan"], 2, width=10)
+ self._entry(section, "AWG VISA", self._vm.awg_visa, 3)
+ self._entry(section, "AWG IP", self._vm.awg_ip, 4)
+ self._combo(section, "OSC conn", self._vm.osc_connect_mode, ["auto", "lan"], 5, width=10)
+ self._entry(section, "OSC VISA", self._vm.osc_visa, 6)
+ self._entry(section, "OSC IP", self._vm.osc_ip, 7)
+
+ chips = ttk.Frame(section)
+ chips.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0))
+ chips.grid_columnconfigure((0, 1), weight=1)
+ self.awg_connection_chip = self._status_chip(chips, self._vm.awg_connection_text, 0)
+ self.osc_connection_chip = self._status_chip(chips, self._vm.osc_connection_text, 1)
+
+ def _build_sweep(self, row: int) -> None:
+ section = self._section("Sweep", row)
+ self._combo(section, "Unit", self._vm.freq_unit, Mapping.values_freq_unit, 0, width=10)
+ self._entry(section, "Start", self._vm.start_freq, 1, width=12)
+ self._entry(section, "Stop", self._vm.stop_freq, 2, width=12)
+ self._entry(section, "Step", self._vm.step_freq, 3, width=12)
+ self._entry(section, "Points", self._vm.step_count, 4, width=12)
+ ttk.Checkbutton(section, text="Log sweep", variable=self._vm.is_log).grid(
+ row=5, column=0, columnspan=2, sticky="w", pady=(3, 4)
)
- row += 1
-
- self._separator(row)
- row += 1
-
- for label, variable in (
- ("AWG amp (Vpp)", self._vm.awg_amp),
- ("OSC range", self._vm.osc_range),
- ("OSC offset", self._vm.osc_offset),
- ("OSC points", self._vm.osc_points),
+ self._entry(section, "AWG Vpp", self._vm.awg_amp, 6, width=12)
+ self._combo(section, "AWG imp", self._vm.awg_imp, ["50", "INF"], 7, width=10)
+ self._entry(section, "OSC range", self._vm.osc_range, 8, width=12)
+ self._entry(section, "OSC offset", self._vm.osc_offset, 9, width=12)
+ self._entry(section, "OSC points", self._vm.osc_points, 10, width=12)
+ self._combo(section, "OSC imp", self._vm.osc_imp, ["50", "INF"], 11, width=10)
+ self._combo(section, "Coupling", self._vm.osc_coupling, ["DC", "AC"], 12, width=10)
+
+ def _build_channels(self, row: int) -> None:
+ section = self._section("Channels / Correction", row)
+ self._entry(section, "AWG ch", self._vm.awg_ch, 0, width=8)
+ self._entry(section, "Test ch", self._vm.osc_test_ch, 1, width=8)
+ self._entry(section, "Ref ch", self._vm.osc_ref_ch, 2, width=8)
+ self._entry(section, "Trig ch", self._vm.osc_trig_ch, 3, width=8)
+ self._combo(section, "Correction", self._vm.correction_mode, ["none", "single", "dual"], 4)
+ self._combo(section, "Trigger", self._vm.trigger_mode, ["free_run", "triggered"], 5)
+
+ toggles = ttk.Frame(section)
+ toggles.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(5, 0))
+ for idx, (label, variable) in enumerate(
+ (
+ ("Auto range", self._vm.auto_range),
+ ("Auto reset", self._vm.auto_reset),
+ ("Calibration", self._vm.calibration_enabled),
+ ("Auto save", self._vm.auto_save_data),
+ )
):
- if label == "OSC range":
- add_label("AWG imp", row)
- ttk.Combobox(self, textvariable=self._vm.awg_imp, values=["50", "INF"], width=8).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
- add_label(label, row)
- tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew")
- row += 1
-
- add_label("OSC imp", row)
- ttk.Combobox(self, textvariable=self._vm.osc_imp, values=["50", "INF"], width=8).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- add_label("OSC coup", row)
- ttk.Combobox(self, textvariable=self._vm.osc_coupling, values=["DC", "AC"], width=8).grid(
- row=row, column=1, sticky="ew"
- )
- row += 1
-
- self._separator(row)
- row += 1
-
- for label, variable in (
- ("AWG ch", self._vm.awg_ch),
- ("OSC test ch", self._vm.osc_test_ch),
- ("OSC ref ch", self._vm.osc_ref_ch),
- ("OSC trig ch", self._vm.osc_trig_ch),
- ):
- add_label(label, row)
- tk.Entry(self, textvariable=variable).grid(row=row, column=1, sticky="ew")
- row += 1
-
- add_label("Correction", row)
- ttk.Combobox(self, textvariable=self._vm.correction_mode, values=["none", "single", "dual"], width=10).grid(
- row=row, column=1, sticky="ew"
+ ttk.Checkbutton(toggles, text=label, variable=variable).grid(
+ row=idx // 2,
+ column=idx % 2,
+ sticky="w",
+ padx=(0, 10),
+ pady=1,
+ )
+
+ def _build_settings_actions(self, row: int) -> None:
+ actions = ttk.Frame(self)
+ actions.grid(row=row, column=0, sticky="ew")
+ actions.grid_columnconfigure((0, 1), weight=1)
+ self.btn_save_settings = ttk.Button(actions, text="Save Settings")
+ self.btn_save_settings.grid(row=0, column=0, sticky="ew", padx=(0, 6))
+ self.btn_load_settings = ttk.Button(actions, text="Load Settings")
+ self.btn_load_settings.grid(row=0, column=1, sticky="ew")
+
+ def _combo(
+ self,
+ parent: tk.Misc,
+ label: str,
+ variable: tk.StringVar,
+ values: list[str] | tuple[str, ...],
+ row: int,
+ *,
+ width: int = 16,
+ label_col: int = 0,
+ ) -> ttk.Combobox:
+ self._label(parent, label, row, label_col=label_col)
+ combo = ttk.Combobox(parent, textvariable=variable, values=values, width=width, state="readonly")
+ combo.grid(row=row, column=label_col + 1, sticky="ew", pady=1)
+ return combo
+
+ def _entry(
+ self,
+ parent: tk.Misc,
+ label: str,
+ variable: tk.StringVar,
+ row: int,
+ *,
+ width: int = 18,
+ label_col: int = 0,
+ ) -> ttk.Entry:
+ self._label(parent, label, row, label_col=label_col)
+ entry = ttk.Entry(parent, textvariable=variable, width=width)
+ entry.grid(row=row, column=label_col + 1, sticky="ew", pady=1)
+ return entry
+
+ def _label(self, parent: tk.Misc, text: str, row: int, *, label_col: int = 0) -> None:
+ tk.Label(parent, text=text, bg=CARD_BG, fg=MUTED, anchor="w").grid(
+ row=row,
+ column=label_col,
+ sticky="w",
+ padx=(0 if label_col == 0 else 10, 5),
+ pady=1,
)
- row += 1
- add_label("Trigger", row)
- ttk.Combobox(self, textvariable=self._vm.trigger_mode, values=["free_run", "triggered"], width=10).grid(
- row=row, column=1, sticky="ew"
+ def _status_chip(self, parent: tk.Misc, variable: tk.StringVar, column: int) -> tk.Label:
+ label = tk.Label(
+ parent,
+ textvariable=variable,
+ bg="#fef2f2",
+ fg=RED,
+ anchor="center",
+ padx=6,
+ pady=3,
)
- row += 1
+ label.grid(row=0, column=column, sticky="ew", padx=(0 if column == 0 else 6, 0))
- for label, variable in (
- ("Auto range", self._vm.auto_range),
- ("Auto reset", self._vm.auto_reset),
- ("Enable calibration", self._vm.calibration_enabled),
- ("Auto save data", self._vm.auto_save_data),
- ):
- tk.Checkbutton(self, text=label, variable=variable).grid(row=row, column=0, columnspan=2, sticky="w")
- row += 1
+ def sync_chip(*_args) -> None:
+ bg, fg = _connection_chip_colors(variable.get())
+ label.configure(bg=bg, fg=fg)
- add_label("Figure", row)
- self.cmb_figure = ttk.Combobox(self, textvariable=self._vm.figure_mode, values=["gain", "gain_db"], width=10)
- self.cmb_figure.grid(row=row, column=1, sticky="ew")
- row += 1
+ trace_id = variable.trace_add("write", sync_chip)
+ self._trace_ids.append((variable, trace_id))
+ sync_chip()
+ return label
- add_label("Display", row)
- self.cmb_mag_phase = ttk.Combobox(
- self,
- textvariable=self._vm.magnitude_phase_mode,
- values=["magnitude", "phase", "magnitude_phase"],
- width=14,
- )
- self.cmb_mag_phase.grid(row=row, column=1, sticky="ew")
- row += 1
-
- row = self._build_primary_buttons(row)
- self._build_secondary_buttons(row)
- self.grid_columnconfigure(1, weight=1)
-
- def _separator(self, row: int) -> None:
- ttk.Separator(self, orient=tk.HORIZONTAL).grid(row=row, column=0, columnspan=2, sticky="ew", pady=8)
-
- def _build_primary_buttons(self, row: int) -> int:
- buttons = tk.Frame(self)
- buttons.grid(row=row, column=0, columnspan=2, sticky="ew", pady=10)
-
- self.btn_start = tk.Button(buttons, text="Start", width=9)
- self.btn_start.pack(side=tk.LEFT, padx=2)
- self.btn_stop = tk.Button(buttons, text="Stop", width=9, state="disabled")
- self.btn_stop.pack(side=tk.LEFT, padx=2)
-
- self.btn_save_data = tk.Button(buttons, text="Save Data", width=9)
- self.btn_save_data.pack(side=tk.LEFT, padx=2)
- self.btn_load_data = tk.Button(buttons, text="Load Data", width=9)
- self.btn_load_data.pack(side=tk.LEFT, padx=2)
- return row + 1
-
- def _build_secondary_buttons(self, row: int) -> None:
- buttons = tk.Frame(self)
- buttons.grid(row=row, column=0, columnspan=2, sticky="ew", pady=5)
-
- self.btn_load_ref = tk.Button(buttons, text="Load Ref", width=9)
- self.btn_load_ref.pack(side=tk.LEFT, padx=2)
- self.btn_save_settings = tk.Button(buttons, text="Save Settings", width=11)
- self.btn_save_settings.pack(side=tk.LEFT, padx=2)
- self.btn_load_settings = tk.Button(buttons, text="Load Settings", width=11)
- self.btn_load_settings.pack(side=tk.LEFT, padx=2)
+def _connection_chip_colors(text: str) -> tuple[str, str]:
+ normalized = text.lower()
+ if "online" in normalized or "connected" in normalized:
+ return "#ecfdf5", GREEN
+ if "checking" in normalized or "connecting" in normalized or "scan" in normalized:
+ return "#fff7ed", AMBER
+ return "#fef2f2", RED
diff --git a/src/app/presentation/tk/controller.py b/src/app/presentation/tk/controller.py
index 6684e52..2654499 100644
--- a/src/app/presentation/tk/controller.py
+++ b/src/app/presentation/tk/controller.py
@@ -3,6 +3,9 @@
import queue
import threading
from collections.abc import Callable
+from pathlib import Path
+
+import numpy as np
from app.application.events import EventEmitter
from app.application.services.sweep_task_runner import SweepTaskRunner
@@ -21,6 +24,9 @@
from app.runtime.paths import AppPaths
+DEMO_FIXTURE_FILE = Path("demo_data") / "hyperframe_simulated_fixture.mat"
+
+
class TkController(EventEmitter):
def __init__(
self,
@@ -72,6 +78,7 @@ def initialize(self) -> None:
on_stop=self.on_stop,
on_save_data=self.on_save_data,
on_load_data=self.on_load_data,
+ on_load_demo_fixture=self.on_load_demo_fixture,
on_load_ref=self.on_load_reference,
on_save_settings=self.on_save_settings,
on_load_settings=self.on_load_settings,
@@ -101,6 +108,7 @@ def on_start(self) -> None:
try:
settings = vm_to_settings(self.vm)
+ self._ui_handler.set_live_source()
self._refresh_connection_targets(settings)
self._task_runner.start(
settings=settings,
@@ -155,6 +163,7 @@ def on_save_data(self) -> None:
settings=settings,
target=dialogs_to_target(fp, self.window),
)
+ self._ui_handler.set_export_saved(path_name=artifacts.mat_path.name)
dialogs.show_info(self.window, f"Saved: {artifacts.mat_path.name}")
except Exception as exc: # noqa: BLE001
dialogs.show_warning(self.window, f"Failed to save data: {exc}")
@@ -169,12 +178,27 @@ def on_load_data(self) -> None:
return
try:
- loaded = self.load_measurement_use_case.execute(str(fp))
- self._ui_handler.set_result(loaded.result)
+ self._load_measurement_from_path(Path(fp))
dialogs.show_info(self.window, "Measurement loaded")
except Exception as exc: # noqa: BLE001
dialogs.show_warning(self.window, f"Failed to load data: {exc}")
+ def on_load_demo_fixture(self) -> None:
+ fixture_path = self._paths.root_dir / DEMO_FIXTURE_FILE
+ if not fixture_path.exists():
+ dialogs.show_warning(
+ self.window,
+ f"Demo fixture not found: {fixture_path}",
+ )
+ return
+
+ try:
+ self._load_measurement_from_path(fixture_path, force_fixture=True)
+ point_count = self.vm.point_count_text.get()
+ self.vm.status_text.set(f"Demo fixture loaded ({point_count}; no hardware)")
+ except Exception as exc: # noqa: BLE001
+ dialogs.show_warning(self.window, f"Failed to load demo fixture: {exc}")
+
def on_load_reference(self) -> None:
fp = dialogs.ask_open_file(
title="Load reference",
@@ -188,6 +212,7 @@ def on_load_reference(self) -> None:
_curve, interpolator = self.load_reference_use_case.execute(str(fp))
self._reference_interpolator = interpolator
self.vm.calibration_enabled.set(True)
+ self._ui_handler.set_reference_loaded(path_name=Path(fp).name)
dialogs.show_info(self.window, "Reference loaded")
except Exception as exc: # noqa: BLE001
dialogs.show_warning(self.window, f"Failed to load reference: {exc}")
@@ -198,6 +223,15 @@ def on_figure_change(self) -> None:
def on_mag_phase_change(self) -> None:
self._ui_handler.refresh_plot()
+ def _load_measurement_from_path(self, path: Path, *, force_fixture: bool = False) -> None:
+ loaded = self.load_measurement_use_case.execute(str(path))
+ self._ui_handler.set_result(loaded.result)
+ source = _describe_loaded_source(path=path, raw_payload=loaded.raw_payload)
+ if force_fixture or source["fixture"]:
+ self._ui_handler.set_fixture_source(label=source["label"], path_name=path.name)
+ else:
+ self._ui_handler.set_loaded_source(path_name=path.name)
+
def on_close(self) -> None:
self._closing = True
self._monitor.stop()
@@ -255,3 +289,28 @@ def dialogs_to_target(path, window: AppWindow):
include_timestamp=False,
figures=window.plot_widget.figures(),
)
+
+
+def _describe_loaded_source(path: Path, raw_payload: dict[str, np.ndarray]) -> dict[str, object]:
+ source = _payload_text(raw_payload, "source")
+ label = _payload_text(raw_payload, "demo_label") or "Loaded fixture"
+ is_fixture = source == "mock_fixture" or "hyperframe_simulated_fixture" in path.name
+ return {
+ "fixture": is_fixture,
+ "label": label,
+ }
+
+
+def _payload_text(payload: dict[str, np.ndarray], key: str) -> str:
+ value = payload.get(key)
+ if value is None:
+ return ""
+ arr = np.asarray(value)
+ if arr.dtype.kind in {"U", "S"}:
+ if arr.ndim == 2 and arr.shape[0] == 1:
+ return "".join(str(item) for item in arr[0]).strip()
+ return "".join(str(item) for item in arr.ravel()).strip()
+ squeezed = arr.squeeze()
+ if squeezed.shape == ():
+ return str(squeezed.item()).strip()
+ return ""
diff --git a/src/app/presentation/tk/plot_widget.py b/src/app/presentation/tk/plot_widget.py
index 271412c..0edcbdc 100644
--- a/src/app/presentation/tk/plot_widget.py
+++ b/src/app/presentation/tk/plot_widget.py
@@ -1,51 +1,165 @@
from __future__ import annotations
import tkinter as tk
+from tkinter import ttk
import numpy as np
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
+from app.domain.models import SweepResult
+from app.presentation.tk.view_model import ViewModel
from app.shared.cvt_tools import CvtTools
from app.shared.mapping import Mapping
-from app.domain.models import SweepResult
+PANEL_BG = "#f4f6f8"
+CARD_BG = "#ffffff"
+TEXT = "#1f2937"
+MUTED = "#64748b"
+BLUE = "#1d4ed8"
+PHASE = Mapping.mapping_color_for_phase_line
-class PlotWidget:
- def __init__(self, parent: tk.Misc) -> None:
- self.frame = tk.Frame(parent)
- self._fig_gain = Figure(figsize=(8, 4))
+class PlotWidget:
+ def __init__(self, parent: tk.Misc, vm: ViewModel) -> None:
+ self._vm = vm
+ self.frame = tk.Frame(parent, bg=PANEL_BG)
+ self.frame.grid_rowconfigure(1, weight=1)
+ self.frame.grid_columnconfigure(0, weight=1)
+
+ self._build_header()
+ self._build_plots()
+
+ def bind_controls(self, *, on_figure_change, on_mag_phase_change) -> None:
+ self.cmb_figure.bind("<>", lambda _e: on_figure_change())
+ self.cmb_mag_phase.bind("<>", lambda _e: on_mag_phase_change())
+
+ def _build_header(self) -> None:
+ header = tk.Frame(self.frame, bg=CARD_BG, highlightbackground="#d9e0e8", highlightthickness=1)
+ header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
+ header.grid_columnconfigure(0, weight=1)
+
+ left = tk.Frame(header, bg=CARD_BG)
+ left.grid(row=0, column=0, sticky="ew", padx=12, pady=10)
+ left.grid_columnconfigure(0, weight=1)
+
+ tk.Label(
+ left,
+ text="Measurement Results Workbench",
+ bg=CARD_BG,
+ fg=TEXT,
+ font=("TkDefaultFont", 15, "bold"),
+ anchor="w",
+ ).grid(row=0, column=0, sticky="ew")
+ tk.Label(
+ left,
+ textvariable=self._vm.data_source_text,
+ bg=CARD_BG,
+ fg=MUTED,
+ anchor="w",
+ ).grid(row=1, column=0, sticky="ew", pady=(3, 0))
+ self._fixture_badge = tk.Label(
+ left,
+ textvariable=self._vm.fixture_badge_text,
+ bg="#fff7ed",
+ fg="#b45309",
+ anchor="w",
+ padx=6,
+ pady=2,
+ )
+ self._fixture_badge_trace = self._vm.fixture_badge_text.trace_add("write", self._sync_fixture_badge)
+ self._sync_fixture_badge()
+
+ controls = tk.Frame(header, bg=CARD_BG)
+ controls.grid(row=0, column=1, sticky="e", padx=12, pady=10)
+ controls.grid_columnconfigure(1, weight=1)
+
+ tk.Label(controls, text="Plot", bg=CARD_BG, fg=MUTED).grid(row=0, column=0, sticky="e", padx=(0, 6))
+ self.cmb_figure = ttk.Combobox(
+ controls,
+ textvariable=self._vm.figure_mode,
+ values=["gain", "gain_db"],
+ width=10,
+ state="readonly",
+ )
+ self.cmb_figure.grid(row=0, column=1, sticky="ew")
+
+ tk.Label(controls, text="Display", bg=CARD_BG, fg=MUTED).grid(row=1, column=0, sticky="e", padx=(0, 6))
+ self.cmb_mag_phase = ttk.Combobox(
+ controls,
+ textvariable=self._vm.magnitude_phase_mode,
+ values=["magnitude", "phase", "magnitude_phase"],
+ width=16,
+ state="readonly",
+ )
+ self.cmb_mag_phase.grid(row=1, column=1, sticky="ew", pady=(5, 0))
+
+ tk.Label(
+ controls,
+ textvariable=self._vm.point_count_text,
+ bg=CARD_BG,
+ fg=BLUE,
+ font=("TkDefaultFont", 10, "bold"),
+ anchor="e",
+ ).grid(row=2, column=0, columnspan=2, sticky="ew", pady=(7, 0))
+
+ def _build_plots(self) -> None:
+ plot_card = tk.Frame(self.frame, bg=CARD_BG, highlightbackground="#d9e0e8", highlightthickness=1)
+ plot_card.grid(row=1, column=0, sticky="nsew")
+ plot_card.grid_rowconfigure(0, weight=1)
+ plot_card.grid_columnconfigure(0, weight=1)
+
+ self._fig_gain = self._figure()
self._ax_gain = self._fig_gain.add_subplot(111)
- self._ax_gain.set_ylabel("Gain")
- (self._line_gain,) = self._ax_gain.plot([], [], "-")
+ self._style_axes(self._ax_gain, "Gain", "Frequency")
+ (self._line_gain,) = self._ax_gain.plot([], [], "-", color=BLUE, linewidth=1.7)
self._ax_gain_right = self._ax_gain.twinx()
self._ax_gain_right.set_ylabel("Phase (deg)")
- (self._line_phase_gain,) = self._ax_gain_right.plot([], [], ":", color=Mapping.mapping_color_for_phase_line)
+ (self._line_phase_gain,) = self._ax_gain_right.plot([], [], ":", color=PHASE, linewidth=1.4)
- self._canvas_gain = FigureCanvasTkAgg(self._fig_gain, master=self.frame)
- self._canvas_gain.get_tk_widget().pack(fill=tk.BOTH, expand=True)
+ self._canvas_gain = FigureCanvasTkAgg(self._fig_gain, master=plot_card)
+ self._canvas_gain.get_tk_widget().grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
- self._fig_db = Figure(figsize=(8, 4))
+ self._fig_db = self._figure()
self._ax_db = self._fig_db.add_subplot(111)
- self._ax_db.set_ylabel("Gain (dB)")
- (self._line_db,) = self._ax_db.plot([], [], "-")
+ self._style_axes(self._ax_db, "Gain (dB)", "Frequency")
+ (self._line_db,) = self._ax_db.plot([], [], "-", color=BLUE, linewidth=1.7)
self._ax_db_right = self._ax_db.twinx()
self._ax_db_right.set_ylabel("Phase (deg)")
- (self._line_phase_db,) = self._ax_db_right.plot([], [], ":", color=Mapping.mapping_color_for_phase_line)
+ (self._line_phase_db,) = self._ax_db_right.plot([], [], ":", color=PHASE, linewidth=1.4)
- self._canvas_db = FigureCanvasTkAgg(self._fig_db, master=self.frame)
+ self._canvas_db = FigureCanvasTkAgg(self._fig_db, master=plot_card)
+
+ def _sync_fixture_badge(self, *_args) -> None:
+ if self._vm.fixture_badge_text.get().strip():
+ self._fixture_badge.grid(row=2, column=0, sticky="w", pady=(6, 0))
+ else:
+ self._fixture_badge.grid_remove()
+
+ def _figure(self) -> Figure:
+ fig = Figure(figsize=(8, 5), facecolor=CARD_BG)
+ fig.subplots_adjust(left=0.08, right=0.9, top=0.94, bottom=0.12)
+ return fig
+
+ def _style_axes(self, ax, ylabel: str, xlabel: str) -> None:
+ ax.set_facecolor("#fbfdff")
+ ax.set_ylabel(ylabel)
+ ax.set_xlabel(xlabel)
+ ax.grid(True, color="#e2e8f0", linewidth=0.8)
+ ax.tick_params(colors=TEXT)
+ for spine in ax.spines.values():
+ spine.set_color("#cbd5e1")
def set_mode(self, mode: str) -> None:
if mode == "gain_db":
- self._canvas_gain.get_tk_widget().pack_forget()
- self._canvas_db.get_tk_widget().pack(fill=tk.BOTH, expand=True)
+ self._canvas_gain.get_tk_widget().grid_remove()
+ self._canvas_db.get_tk_widget().grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
else:
- self._canvas_db.get_tk_widget().pack_forget()
- self._canvas_gain.get_tk_widget().pack(fill=tk.BOTH, expand=True)
+ self._canvas_db.get_tk_widget().grid_remove()
+ self._canvas_gain.get_tk_widget().grid(row=0, column=0, sticky="nsew", padx=10, pady=10)
def update_result(self, result: SweepResult, freq_unit: str, mag_phase_mode: str) -> None:
freq_hz = np.array([p.freq_hz for p in result.points], dtype=float)
@@ -88,18 +202,14 @@ def update_result(self, result: SweepResult, freq_unit: str, mag_phase_mode: str
self._ax_gain.set_xlabel(f"Frequency ({freq_unit})")
self._ax_db.set_xlabel(f"Frequency ({freq_unit})")
- self._ax_gain.relim()
- self._ax_gain.autoscale_view()
- self._ax_gain_right.relim()
- self._ax_gain_right.autoscale_view()
-
- self._ax_db.relim()
- self._ax_db.autoscale_view()
- self._ax_db_right.relim()
- self._ax_db_right.autoscale_view()
-
+ self._autoscale()
self._canvas_gain.draw_idle()
self._canvas_db.draw_idle()
+ def _autoscale(self) -> None:
+ for ax in (self._ax_gain, self._ax_gain_right, self._ax_db, self._ax_db_right):
+ ax.relim()
+ ax.autoscale_view()
+
def figures(self) -> dict[str, Figure]:
return {"gain": self._fig_gain, "db": self._fig_db}
diff --git a/src/app/presentation/tk/run_panel.py b/src/app/presentation/tk/run_panel.py
new file mode 100644
index 0000000..7dcb82b
--- /dev/null
+++ b/src/app/presentation/tk/run_panel.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+import tkinter as tk
+from tkinter import ttk
+
+from app.presentation.tk.view_model import ViewModel
+
+
+PANEL_BG = "#f4f6f8"
+CARD_BG = "#ffffff"
+TEXT = "#1f2937"
+MUTED = "#64748b"
+GREEN = "#15803d"
+AMBER = "#b45309"
+RED = "#b91c1c"
+
+
+class RunPanel(tk.Frame):
+ def __init__(self, parent: tk.Misc, vm: ViewModel) -> None:
+ super().__init__(parent, bg=PANEL_BG)
+ self._vm = vm
+ self._build()
+
+ def bind_actions(
+ self,
+ *,
+ on_start,
+ on_stop,
+ on_save_data,
+ on_load_data,
+ on_load_demo_fixture,
+ on_load_ref,
+ on_save_settings,
+ on_load_settings,
+ ) -> None:
+ self.btn_start.configure(command=on_start)
+ self.btn_stop.configure(command=on_stop)
+ self.btn_save_data.configure(command=on_save_data)
+ self.btn_load_data.configure(command=on_load_data)
+ self.btn_load_demo_fixture.configure(command=on_load_demo_fixture)
+ self.btn_load_ref.configure(command=on_load_ref)
+ self.btn_save_settings.configure(command=on_save_settings)
+ self.btn_load_settings.configure(command=on_load_settings)
+
+ def _build(self) -> None:
+ self.grid_columnconfigure(0, weight=1)
+
+ self._title("Run / Status", 0)
+ self._build_run_card(1)
+ self._build_export_card(2)
+ self._build_source_card(3)
+ self._build_safety_card(4)
+
+ def _title(self, text: str, row: int) -> None:
+ label = tk.Label(
+ self,
+ text=text,
+ bg=PANEL_BG,
+ fg=TEXT,
+ font=("TkDefaultFont", 14, "bold"),
+ anchor="w",
+ )
+ label.grid(row=row, column=0, sticky="ew", pady=(0, 10))
+
+ def _card(self, title: str, row: int) -> ttk.LabelFrame:
+ card = ttk.LabelFrame(self, text=title, padding=(10, 8))
+ card.grid(row=row, column=0, sticky="ew", pady=(0, 10))
+ card.grid_columnconfigure(0, weight=1)
+ return card
+
+ def _build_run_card(self, row: int) -> None:
+ card = self._card("Sweep control", row)
+
+ metrics = tk.Frame(card, bg=CARD_BG)
+ metrics.grid(row=0, column=0, sticky="ew")
+ metrics.grid_columnconfigure((0, 1), weight=1)
+
+ self._metric(metrics, "State", self._vm.run_state_text, 0, 0, GREEN)
+ self._metric(metrics, "Progress", self._vm.progress_text, 0, 1, AMBER)
+ self._metric(metrics, "Elapsed", self._vm.elapsed_text, 1, 0, MUTED)
+ self._metric(metrics, "Latest frequency", self._vm.latest_frequency_text, 1, 1, MUTED)
+
+ buttons = ttk.Frame(card)
+ buttons.grid(row=1, column=0, sticky="ew", pady=(10, 0))
+ buttons.grid_columnconfigure((0, 1), weight=1)
+
+ self.btn_start = tk.Button(
+ buttons,
+ text="Start",
+ bg=GREEN,
+ fg="white",
+ activebackground="#166534",
+ activeforeground="white",
+ relief=tk.FLAT,
+ padx=10,
+ pady=7,
+ )
+ self.btn_start.grid(row=0, column=0, sticky="ew", padx=(0, 6))
+
+ self.btn_stop = tk.Button(
+ buttons,
+ text="Stop",
+ bg=RED,
+ fg="white",
+ activebackground="#991b1b",
+ activeforeground="white",
+ relief=tk.FLAT,
+ padx=10,
+ pady=7,
+ state="disabled",
+ )
+ self.btn_stop.grid(row=0, column=1, sticky="ew")
+
+ def _build_export_card(self, row: int) -> None:
+ card = self._card("Data / Export", row)
+ actions = ttk.Frame(card)
+ actions.grid(row=0, column=0, sticky="ew")
+ actions.grid_columnconfigure((0, 1), weight=1)
+
+ self.btn_load_data = ttk.Button(actions, text="Load Data")
+ self.btn_load_data.grid(row=0, column=0, sticky="ew", padx=(0, 6), pady=(0, 6))
+ self.btn_save_data = ttk.Button(actions, text="Save Data")
+ self.btn_save_data.grid(row=0, column=1, sticky="ew", pady=(0, 6))
+ self.btn_load_ref = ttk.Button(actions, text="Load Ref")
+ self.btn_load_ref.grid(row=1, column=0, sticky="ew", padx=(0, 6), pady=(0, 6))
+ self.btn_load_demo_fixture = tk.Button(
+ actions,
+ text="Load Demo Fixture",
+ bg=AMBER,
+ fg="white",
+ activebackground="#92400e",
+ activeforeground="white",
+ relief=tk.FLAT,
+ padx=8,
+ pady=5,
+ )
+ self.btn_load_demo_fixture.grid(row=1, column=1, sticky="ew", pady=(0, 6))
+
+ self.btn_save_settings = ttk.Button(actions, text="Save Settings")
+ self.btn_save_settings.grid(row=2, column=0, sticky="ew", padx=(0, 6))
+ self.btn_load_settings = ttk.Button(actions, text="Load Settings")
+ self.btn_load_settings.grid(row=2, column=1, sticky="ew")
+
+ self._wrapped_label(card, self._vm.export_receipt_text, 1, MUTED)
+
+ def _build_source_card(self, row: int) -> None:
+ card = self._card("Source receipt", row)
+ self._wrapped_label(card, self._vm.data_source_text, 0, TEXT, bold=True)
+ self._fixture_badge = tk.Label(
+ card,
+ textvariable=self._vm.fixture_badge_text,
+ bg="#fff7ed",
+ fg=AMBER,
+ anchor="w",
+ padx=6,
+ pady=3,
+ )
+ self._fixture_badge_trace = self._vm.fixture_badge_text.trace_add("write", self._sync_fixture_badge)
+ self._sync_fixture_badge()
+ self._wrapped_label(card, self._vm.validation_receipt_text, 2, MUTED)
+
+ def _build_safety_card(self, row: int) -> None:
+ card = self._card("Operator safety", row)
+ items = [
+ "Model and address reviewed",
+ "AWG amplitude and DUT limits checked",
+ "Impedance and coupling confirmed",
+ "Stop closes active AWG output",
+ ]
+ for idx, item in enumerate(items):
+ line = ttk.Frame(card)
+ line.grid(row=idx, column=0, sticky="ew", pady=2)
+ tk.Label(line, text="□", bg=CARD_BG, fg=AMBER, width=2, anchor="w").pack(side=tk.LEFT)
+ tk.Label(line, text=item, bg=CARD_BG, fg=TEXT, anchor="w").pack(side=tk.LEFT, fill=tk.X, expand=True)
+
+ def _metric(
+ self,
+ parent: tk.Misc,
+ label: str,
+ variable: tk.StringVar,
+ row: int,
+ column: int,
+ color: str,
+ ) -> None:
+ box = tk.Frame(parent, bg=CARD_BG, highlightbackground="#d9e0e8", highlightthickness=1)
+ box.grid(row=row, column=column, sticky="ew", padx=(0 if column == 0 else 6, 0), pady=(0, 6))
+ tk.Label(box, text=label, bg=CARD_BG, fg=MUTED, font=("TkDefaultFont", 9)).pack(anchor="w", padx=7, pady=(5, 0))
+ tk.Label(
+ box,
+ textvariable=variable,
+ bg=CARD_BG,
+ fg=color,
+ font=("TkDefaultFont", 11, "bold"),
+ ).pack(anchor="w", padx=7, pady=(0, 6))
+
+ def _wrapped_label(
+ self,
+ parent: tk.Misc,
+ variable: tk.StringVar,
+ row: int,
+ color: str,
+ *,
+ bold: bool = False,
+ ) -> None:
+ tk.Label(
+ parent,
+ textvariable=variable,
+ bg=CARD_BG,
+ fg=color,
+ justify=tk.LEFT,
+ anchor="w",
+ wraplength=250,
+ font=("TkDefaultFont", 10, "bold" if bold else "normal"),
+ ).grid(row=row, column=0, sticky="ew")
+
+ def _sync_fixture_badge(self, *_args) -> None:
+ if self._vm.fixture_badge_text.get().strip():
+ self._fixture_badge.grid(row=1, column=0, sticky="ew", pady=(6, 4))
+ else:
+ self._fixture_badge.grid_remove()
diff --git a/src/app/presentation/tk/ui_event_handler.py b/src/app/presentation/tk/ui_event_handler.py
index 0330dea..f554711 100644
--- a/src/app/presentation/tk/ui_event_handler.py
+++ b/src/app/presentation/tk/ui_event_handler.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import time
+
from app.application.events import (
ConnectionStatusUpdated,
SweepCompleted,
@@ -21,6 +23,7 @@ def __init__(self, *, window: AppWindow, vm: ViewModel) -> None:
self._window = window
self._vm = vm
self._latest_result = SweepResult()
+ self._started_at: float | None = None
@property
def latest_result(self) -> SweepResult:
@@ -29,13 +32,50 @@ def latest_result(self) -> SweepResult:
def prepare_for_sweep_start(self) -> None:
self._window.btn_start.configure(state="disabled")
self._window.btn_stop.configure(state="normal")
+ self._started_at = time.monotonic()
self._vm.status_text.set("Sweep started")
+ self._vm.run_state_text.set("Running")
+ self._vm.elapsed_text.set("00:00")
+ self._vm.latest_frequency_text.set("-")
+ self._vm.export_receipt_text.set("No export yet")
+ self.set_live_source()
def set_result(self, result: SweepResult, *, refresh_plot: bool = True) -> None:
self._latest_result = result
+ self._vm.point_count_text.set(f"{len(result.points)} points")
if refresh_plot:
self.refresh_plot()
+ def set_live_source(self) -> None:
+ self._vm.data_source_text.set("Live instrument path")
+ self._vm.fixture_badge_text.set("")
+ self._vm.validation_receipt_text.set("Live run requires operator hardware checks")
+
+ def set_fixture_source(self, *, label: str, path_name: str) -> None:
+ point_count = len(self._latest_result.points)
+ self._vm.data_source_text.set(f"Fixture replay · {path_name}")
+ self._vm.fixture_badge_text.set("No hardware - simulated fixture")
+ self._vm.validation_receipt_text.set(f"{label}; not live hardware validation")
+ self._vm.export_receipt_text.set("Fixture loaded; Save Data exports the current result")
+ self._vm.run_state_text.set("Fixture ready")
+ if point_count:
+ self._vm.progress_text.set(f"{point_count} / {point_count}")
+ self._vm.latest_frequency_text.set(_format_frequency(self._latest_result.points[-1].freq_hz))
+ self._vm.elapsed_text.set("00:00")
+
+ def set_loaded_source(self, *, path_name: str) -> None:
+ self._vm.data_source_text.set(f"Loaded measurement · {path_name}")
+ self._vm.fixture_badge_text.set("")
+ self._vm.validation_receipt_text.set("Loaded file; live hardware state not implied")
+ self._vm.export_receipt_text.set("Loaded measurement; Save Data exports the current result")
+ self._vm.run_state_text.set("Data loaded")
+
+ def set_reference_loaded(self, *, path_name: str) -> None:
+ self._vm.validation_receipt_text.set(f"Reference loaded: {path_name}")
+
+ def set_export_saved(self, *, path_name: str) -> None:
+ self._vm.export_receipt_text.set(f"Saved measurement: {path_name}")
+
def refresh_plot(self) -> None:
self._window.plot_widget.update_result(
self._latest_result,
@@ -50,10 +90,16 @@ def handle(self, event: object) -> None:
if isinstance(event, SweepStarted):
self._vm.status_text.set(f"Sweep started ({event.total_points} points)")
+ self._vm.run_state_text.set("Running")
+ self._vm.progress_text.set(f"0 / {event.total_points}")
+ self._vm.point_count_text.set(f"0 / {event.total_points} points")
return
if isinstance(event, SweepProgress):
self._vm.status_text.set(f"Freq {event.freq_hz:.2f} Hz ({event.point_index}/{event.total_points})")
+ self._vm.progress_text.set(f"{event.point_index} / {event.total_points}")
+ self._vm.latest_frequency_text.set(_format_frequency(event.freq_hz))
+ self._vm.elapsed_text.set(_format_elapsed(self._started_at))
return
if isinstance(event, SweepDataUpdated):
@@ -69,6 +115,7 @@ def handle(self, event: object) -> None:
if isinstance(event, SweepFailed):
self._vm.status_text.set(f"Sweep failed: {event.message}")
+ self._vm.run_state_text.set("Failed")
self._window.btn_start.configure(state="normal")
self._window.btn_stop.configure(state="disabled")
dialogs.show_warning(self._window, event.message)
@@ -77,6 +124,8 @@ def handle(self, event: object) -> None:
if isinstance(event, SweepStopped):
self.set_result(event.result, refresh_plot=False)
self._vm.status_text.set("Sweep stopped")
+ self._vm.run_state_text.set("Stopped")
+ self._vm.elapsed_text.set(_format_elapsed(self._started_at))
self._window.btn_start.configure(state="normal")
self._window.btn_stop.configure(state="disabled")
return
@@ -84,5 +133,23 @@ def handle(self, event: object) -> None:
if isinstance(event, SweepCompleted):
self.set_result(event.result)
self._vm.status_text.set("Sweep completed")
+ self._vm.run_state_text.set("Completed")
+ self._vm.progress_text.set(f"{len(event.result.points)} / {len(event.result.points)}")
+ self._vm.elapsed_text.set(_format_elapsed(self._started_at))
self._window.btn_start.configure(state="normal")
self._window.btn_stop.configure(state="disabled")
+
+
+def _format_elapsed(started_at: float | None) -> str:
+ if started_at is None:
+ return "00:00"
+ seconds = max(0, int(time.monotonic() - started_at))
+ return f"{seconds // 60:02d}:{seconds % 60:02d}"
+
+
+def _format_frequency(freq_hz: float) -> str:
+ if abs(freq_hz) >= 1_000_000:
+ return f"{freq_hz / 1_000_000:.3g} MHz"
+ if abs(freq_hz) >= 1_000:
+ return f"{freq_hz / 1_000:.3g} kHz"
+ return f"{freq_hz:.3g} Hz"
diff --git a/src/app/presentation/tk/view_model.py b/src/app/presentation/tk/view_model.py
index 0ee8146..4d14123 100644
--- a/src/app/presentation/tk/view_model.py
+++ b/src/app/presentation/tk/view_model.py
@@ -49,3 +49,14 @@ def __init__(self, root: tk.Misc) -> None:
self.magnitude_phase_mode = tk.StringVar(root, value="magnitude")
self.status_text = tk.StringVar(root, value="Ready")
+ self.run_state_text = tk.StringVar(root, value="Idle")
+ self.progress_text = tk.StringVar(root, value="0 / 0")
+ self.elapsed_text = tk.StringVar(root, value="00:00")
+ self.latest_frequency_text = tk.StringVar(root, value="-")
+ self.point_count_text = tk.StringVar(root, value="0 points")
+ self.data_source_text = tk.StringVar(root, value="Live instrument path")
+ self.fixture_badge_text = tk.StringVar(root, value="")
+ self.validation_receipt_text = tk.StringVar(root, value="Live run requires operator hardware checks")
+ self.export_receipt_text = tk.StringVar(root, value="No export yet")
+ self.awg_connection_text = tk.StringVar(root, value="AWG offline")
+ self.osc_connection_text = tk.StringVar(root, value="OSC offline")
diff --git a/tests/test_hyperframe_fixture.py b/tests/test_hyperframe_fixture.py
new file mode 100644
index 0000000..de05530
--- /dev/null
+++ b/tests/test_hyperframe_fixture.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+from pathlib import Path
+import unittest
+
+import numpy as np
+from scipy.io import loadmat
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+
+from app.demo.hyperframe_fixture import ( # noqa: E402
+ DEMO_LABEL,
+ POINT_COUNT,
+ SOURCE,
+ build_fixture_curves,
+ build_measurement_payload,
+ write_fixture,
+)
+from app.infrastructure.persistence.measurement_loader import MeasurementLoader # noqa: E402
+from app.infrastructure.persistence.reference_repo_mat import MatReferenceRepository # noqa: E402
+
+
+class HyperframeFixtureTests(unittest.TestCase):
+ def test_fixture_payload_has_demo_metadata_and_non_smooth_curve(self) -> None:
+ curves = build_fixture_curves()
+ payload = build_measurement_payload(curves)
+
+ self.assertEqual(payload["source"], SOURCE)
+ self.assertEqual(payload["demo_label"], DEMO_LABEL)
+ self.assertEqual(payload["point_count"], POINT_COUNT)
+ self.assertEqual(payload["correction_mode"], "dual")
+ self.assertEqual(payload["trigger_mode"], "triggered")
+
+ metadata = json.loads(str(payload["metadata_json"]))
+ self.assertEqual(metadata["demo_fixture"]["source"], "mock_fixture")
+ self.assertIn("No hardware", metadata["demo_fixture"]["validation_boundary"])
+
+ freq = curves["freq_hz"]
+ gain = curves["gain_db_corrected"]
+ self.assertEqual(len(freq), POINT_COUNT)
+ self.assertTrue(np.all(np.diff(freq) > 0.0))
+
+ # The fixture should look measured, not like a perfectly smooth textbook curve.
+ x = np.log10(freq)
+ residual = gain - np.polyval(np.polyfit(x, gain, deg=5), x)
+ self.assertGreater(float(np.std(residual)), 0.015)
+
+ def test_written_fixture_loads_through_existing_measurement_and_reference_paths(self) -> None:
+ with tempfile.TemporaryDirectory() as td:
+ paths = write_fixture(Path(td))
+ loaded_mat = MeasurementLoader().load(str(paths["measurement_mat"]))
+ loaded_csv = MeasurementLoader().load(str(paths["measurement_csv"]))
+ reference = MatReferenceRepository().load_reference(str(paths["reference_mat"]))
+ mat_payload = loadmat(paths["measurement_mat"])
+
+ self.assertEqual(len(loaded_mat.result.points), POINT_COUNT)
+ self.assertEqual(len(loaded_csv.result.points), POINT_COUNT)
+ self.assertIsNotNone(loaded_mat.result.points[0].phase_deg)
+ self.assertEqual(len(reference.freq_hz), POINT_COUNT)
+ self.assertIsNotNone(reference.phase_deg)
+
+ self.assertEqual(_mat_string(mat_payload["source"]), SOURCE)
+ self.assertEqual(_mat_string(mat_payload["demo_label"]), DEMO_LABEL)
+ self.assertIn("gain_db_raw", mat_payload)
+ self.assertIn("gain_db_reference", mat_payload)
+ self.assertIn("gain_db_corrected", mat_payload)
+
+ def test_written_fixture_is_binary_reproducible(self) -> None:
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
+ first_paths = write_fixture(Path(first))
+ second_paths = write_fixture(Path(second))
+
+ self.assertEqual(
+ first_paths["measurement_mat"].read_bytes(),
+ second_paths["measurement_mat"].read_bytes(),
+ )
+ self.assertEqual(
+ first_paths["reference_mat"].read_bytes(),
+ second_paths["reference_mat"].read_bytes(),
+ )
+
+
+def _mat_string(value: object) -> str:
+ arr = np.asarray(value)
+ if arr.dtype.kind in {"U", "S"}:
+ if arr.ndim == 2 and arr.shape[0] == 1:
+ return "".join(str(item) for item in arr[0]).strip()
+ return "".join(str(item) for item in arr.ravel()).strip()
+ return str(arr.squeeze())
+
+
+if __name__ == "__main__":
+ unittest.main()