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`: ![Demo sweep result](docs/images/sweep_result.png) +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()