From 8f460471be7f6e0f6f989dc8fc1f95ce17006158 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Wed, 24 Sep 2025 18:52:34 +0300 Subject: [PATCH] added calibration --- vna_system/api/endpoints/settings.py | 301 ++ vna_system/api/main.py | 3 +- vna_system/api/models/settings.py | 59 + ...1_start100_stop8800_points1000_bw1khz.bin} | Bin ...1_start100_stop8800_points1000_bw1khz.bin} | Bin 78742 -> 61824 bytes vna_system/binary_input/current_input.bin | 1 + vna_system/calibration/current_calibration | 1 + .../lol/calibration_info.json | 18 + .../lol/load.json | 4007 +++++++++++++++++ .../lol/load_metadata.json | 16 + .../lol/open.json | 4007 +++++++++++++++++ .../lol/open_metadata.json | 16 + .../lol/short.json | 4007 +++++++++++++++++ .../lol/short_metadata.json | 16 + .../tuncTuncTuncSahur/calibration_info.json | 18 + .../tuncTuncTuncSahur/load.json | 4007 +++++++++++++++++ .../tuncTuncTuncSahur/load_metadata.json | 16 + .../tuncTuncTuncSahur/open.json | 4007 +++++++++++++++++ .../tuncTuncTuncSahur/open_metadata.json | 16 + .../tuncTuncTuncSahur/short.json | 4007 +++++++++++++++++ .../tuncTuncTuncSahur/short_metadata.json | 16 + .../bimBimPatapim/calibration_info.json | 16 + .../bimBimPatapim/through.json | 4007 +++++++++++++++++ .../bimBimPatapim/through_metadata.json | 16 + vna_system/config/config.py | 2 +- .../core/acquisition/data_acquisition.py | 3 +- .../core/processing/calibration_processor.py | 134 + .../processing/processors/magnitude_plot.py | 75 +- .../core/settings/calibration_manager.py | 315 ++ vna_system/core/settings/preset_manager.py | 129 + vna_system/core/settings/settings_manager.py | 436 +- vna_system/web_ui/static/css/settings.css | 492 ++ vna_system/web_ui/static/js/main.js | 14 +- .../web_ui/static/js/modules/settings.js | 585 +++ vna_system/web_ui/templates/index.html | 122 +- 35 files changed, 30560 insertions(+), 325 deletions(-) create mode 100644 vna_system/api/endpoints/settings.py create mode 100644 vna_system/api/models/settings.py rename vna_system/{binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin => binary_input/config_inputs/s11_start100_stop8800_points1000_bw1khz.bin} (100%) rename vna_system/{binary_logs/current_log.bin => binary_input/config_inputs/s21_start100_stop8800_points1000_bw1khz.bin} (56%) create mode 120000 vna_system/binary_input/current_input.bin create mode 120000 vna_system/calibration/current_calibration create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/calibration_info.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/load.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/load_metadata.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/open.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/open_metadata.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/short.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/lol/short_metadata.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/calibration_info.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/load.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/load_metadata.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/open.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/open_metadata.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/short.json create mode 100644 vna_system/calibration/s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur/short_metadata.json create mode 100644 vna_system/calibration/s21_start100_stop8800_points1000_bw1khz/bimBimPatapim/calibration_info.json create mode 100644 vna_system/calibration/s21_start100_stop8800_points1000_bw1khz/bimBimPatapim/through.json create mode 100644 vna_system/calibration/s21_start100_stop8800_points1000_bw1khz/bimBimPatapim/through_metadata.json create mode 100644 vna_system/core/processing/calibration_processor.py create mode 100644 vna_system/core/settings/calibration_manager.py create mode 100644 vna_system/core/settings/preset_manager.py create mode 100644 vna_system/web_ui/static/css/settings.css create mode 100644 vna_system/web_ui/static/js/modules/settings.js diff --git a/vna_system/api/endpoints/settings.py b/vna_system/api/endpoints/settings.py new file mode 100644 index 0000000..0ecceef --- /dev/null +++ b/vna_system/api/endpoints/settings.py @@ -0,0 +1,301 @@ +from fastapi import APIRouter, HTTPException +from typing import List + +import vna_system.core.singletons as singletons +from vna_system.core.settings.calibration_manager import CalibrationStandard +from vna_system.api.models.settings import ( + PresetModel, + CalibrationModel, + SettingsStatusModel, + SetPresetRequest, + StartCalibrationRequest, + CalibrateStandardRequest, + SaveCalibrationRequest, + SetCalibrationRequest, + RemoveStandardRequest, + WorkingCalibrationModel +) + +router = APIRouter(prefix="/api/v1/settings", tags=["settings"]) + + +@router.get("/status", response_model=SettingsStatusModel) +async def get_status(): + """Get current settings status""" + try: + status = singletons.settings_manager.get_status_summary() + return status + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/presets", response_model=List[PresetModel]) +async def get_presets(mode: str | None = None): + """Get all available configuration presets, optionally filtered by mode""" + try: + if mode: + from vna_system.core.settings.preset_manager import VNAMode + try: + vna_mode = VNAMode(mode.lower()) + presets = singletons.settings_manager.get_presets_by_mode(vna_mode) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}") + else: + presets = singletons.settings_manager.get_available_presets() + + return [ + PresetModel( + filename=preset.filename, + mode=preset.mode.value, + start_freq=preset.start_freq, + stop_freq=preset.stop_freq, + points=preset.points, + bandwidth=preset.bandwidth + ) + for preset in presets + ] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/preset/set") +async def set_preset(request: SetPresetRequest): + """Set current configuration preset""" + try: + # Find preset by filename + presets = singletons.settings_manager.get_available_presets() + preset = next((p for p in presets if p.filename == request.filename), None) + + if not preset: + raise HTTPException(status_code=404, detail=f"Preset not found: {request.filename}") + + # Clear current calibration when changing preset + singletons.settings_manager.calibration_manager.clear_current_calibration() + + singletons.settings_manager.set_current_preset(preset) + return {"success": True, "message": f"Preset set to {request.filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/preset/current", response_model=PresetModel | None) +async def get_current_preset(): + """Get currently selected configuration preset""" + try: + preset = singletons.settings_manager.get_current_preset() + if not preset: + return None + + return PresetModel( + filename=preset.filename, + mode=preset.mode.value, + start_freq=preset.start_freq, + stop_freq=preset.stop_freq, + points=preset.points, + bandwidth=preset.bandwidth + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/calibrations", response_model=List[CalibrationModel]) +async def get_calibrations(preset_filename: str | None = None): + """Get available calibrations for current or specified preset""" + try: + preset = None + if preset_filename: + presets = singletons.settings_manager.get_available_presets() + preset = next((p for p in presets if p.filename == preset_filename), None) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}") + + calibrations = singletons.settings_manager.get_available_calibrations(preset) + + # Get detailed info for each calibration + calibration_details = [] + current_preset = preset or singletons.settings_manager.get_current_preset() + + if current_preset: + for calib_name in calibrations: + info = singletons.settings_manager.get_calibration_info(calib_name, current_preset) + + # Convert standards format if needed + standards = info.get('standards', {}) + if isinstance(standards, list): + # If standards is a list (from complete calibration), convert to dict + required_standards = singletons.settings_manager.get_required_standards(current_preset.mode) + standards = {std.value: std.value in standards for std in required_standards} + + calibration_details.append(CalibrationModel( + name=calib_name, + is_complete=info.get('is_complete', False), + standards=standards + )) + + return calibration_details + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/calibration/start") +async def start_calibration(request: StartCalibrationRequest): + """Start new calibration for current or specified preset""" + try: + preset = None + if request.preset_filename: + presets = singletons.settings_manager.get_available_presets() + preset = next((p for p in presets if p.filename == request.preset_filename), None) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}") + + calibration_set = singletons.settings_manager.start_new_calibration(preset) + required_standards = singletons.settings_manager.get_required_standards(calibration_set.preset.mode) + + return { + "success": True, + "message": "Calibration started", + "preset": calibration_set.preset.filename, + "required_standards": [s.value for s in required_standards] + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/calibration/add-standard") +async def add_calibration_standard(request: CalibrateStandardRequest): + """Add calibration standard from latest sweep""" + try: + # Validate standard + try: + standard = CalibrationStandard(request.standard) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}") + + # Capture from data acquisition + sweep_number = singletons.settings_manager.capture_calibration_standard_from_acquisition( + standard, singletons.vna_data_acquisition_instance + ) + + # Get current working calibration status + working_calib = singletons.settings_manager.get_current_working_calibration() + progress = working_calib.get_progress() if working_calib else (0, 0) + + return { + "success": True, + "message": f"Added {standard.value} standard from sweep {sweep_number}", + "sweep_number": sweep_number, + "progress": f"{progress[0]}/{progress[1]}", + "is_complete": working_calib.is_complete() if working_calib else False + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/calibration/save") +async def save_calibration(request: SaveCalibrationRequest): + """Save current working calibration set""" + try: + calibration_set = singletons.settings_manager.save_calibration_set(request.name) + + return { + "success": True, + "message": f"Calibration '{request.name}' saved successfully", + "preset": calibration_set.preset.filename, + "standards": list(calibration_set.standards.keys()) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/calibration/set") +async def set_calibration(request: SetCalibrationRequest): + """Set current active calibration""" + try: + preset = None + if request.preset_filename: + presets = singletons.settings_manager.get_available_presets() + preset = next((p for p in presets if p.filename == request.preset_filename), None) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}") + + singletons.settings_manager.set_current_calibration(request.name, preset) + + return { + "success": True, + "message": f"Calibration set to '{request.name}'" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/working-calibration", response_model=WorkingCalibrationModel) +async def get_working_calibration(): + """Get current working calibration status""" + try: + working_calib = singletons.settings_manager.get_current_working_calibration() + + if not working_calib: + return WorkingCalibrationModel(active=False) + + completed, total = working_calib.get_progress() + missing_standards = working_calib.get_missing_standards() + + return WorkingCalibrationModel( + active=True, + preset=working_calib.preset.filename, + progress=f"{completed}/{total}", + is_complete=working_calib.is_complete(), + completed_standards=[s.value for s in working_calib.standards.keys()], + missing_standards=[s.value for s in missing_standards] + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/calibration/remove-standard") +async def remove_calibration_standard(request: RemoveStandardRequest): + """Remove calibration standard from current working set""" + try: + # Validate standard + try: + standard = CalibrationStandard(request.standard) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}") + + singletons.settings_manager.remove_calibration_standard(standard) + + # Get current working calibration status + working_calib = singletons.settings_manager.get_current_working_calibration() + progress = working_calib.get_progress() if working_calib else (0, 0) + + return { + "success": True, + "message": f"Removed {standard.value} standard", + "progress": f"{progress[0]}/{progress[1]}", + "is_complete": working_calib.is_complete() if working_calib else False + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/calibration/current") +async def get_current_calibration(): + """Get currently selected calibration details""" + try: + current_calib = singletons.settings_manager.get_current_calibration() + + if not current_calib: + return {"active": False} + + return { + "active": True, + "preset": { + "filename": current_calib.preset.filename, + "mode": current_calib.preset.mode.value + }, + "calibration_name": current_calib.name, + "standards": [s.value for s in current_calib.standards.keys()], + "is_complete": current_calib.is_complete() + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/vna_system/api/main.py b/vna_system/api/main.py index fda7b9c..251744d 100644 --- a/vna_system/api/main.py +++ b/vna_system/api/main.py @@ -14,7 +14,7 @@ from pathlib import Path import vna_system.core.singletons as singletons from vna_system.core.processing.sweep_processor import SweepProcessingManager from vna_system.core.processing.websocket_handler import WebSocketManager -from vna_system.api.endpoints import health, processing, web_ui +from vna_system.api.endpoints import health, processing, settings, web_ui from vna_system.api.websockets import processing as ws_processing @@ -117,6 +117,7 @@ else: app.include_router(web_ui.router) # Web UI should be first for root path app.include_router(health.router) app.include_router(processing.router) +app.include_router(settings.router) app.include_router(ws_processing.router) diff --git a/vna_system/api/models/settings.py b/vna_system/api/models/settings.py new file mode 100644 index 0000000..56b5d50 --- /dev/null +++ b/vna_system/api/models/settings.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel +from typing import List, Dict, Any + + +class PresetModel(BaseModel): + filename: str + mode: str + start_freq: float | None + stop_freq: float | None + points: int | None + bandwidth: float | None + + +class CalibrationModel(BaseModel): + name: str + is_complete: bool + standards: Dict[str, bool] + + +class SettingsStatusModel(BaseModel): + current_preset: PresetModel | None + current_calibration: Dict[str, Any] | None + working_calibration: Dict[str, Any] | None + available_presets: int + available_calibrations: int + + +class SetPresetRequest(BaseModel): + filename: str + + +class StartCalibrationRequest(BaseModel): + preset_filename: str | None = None + + +class CalibrateStandardRequest(BaseModel): + standard: str + + +class SaveCalibrationRequest(BaseModel): + name: str + + +class SetCalibrationRequest(BaseModel): + name: str + preset_filename: str | None = None + + +class RemoveStandardRequest(BaseModel): + standard: str + + +class WorkingCalibrationModel(BaseModel): + active: bool + preset: str | None = None + progress: str | None = None + is_complete: bool | None = None + completed_standards: List[str] | None = None + missing_standards: List[str] | None = None \ No newline at end of file diff --git a/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin b/vna_system/binary_input/config_inputs/s11_start100_stop8800_points1000_bw1khz.bin similarity index 100% rename from vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin rename to vna_system/binary_input/config_inputs/s11_start100_stop8800_points1000_bw1khz.bin diff --git a/vna_system/binary_logs/current_log.bin b/vna_system/binary_input/config_inputs/s21_start100_stop8800_points1000_bw1khz.bin similarity index 56% rename from vna_system/binary_logs/current_log.bin rename to vna_system/binary_input/config_inputs/s21_start100_stop8800_points1000_bw1khz.bin index ff27d15a2a0cf61251d006746f3f46ead962ee3a..162c9fa01932b05a7cf7e58e03f4c3497f2975aa 100644 GIT binary patch delta 11836 zcmX}yc~no&{|E4QyP~8;i&mAWD5>{*UURc0+lM6KL$YRHvyQg{X)kwBXn0`z!z5nK|d4bI*C)*L}_0XUu!~eQ)G0^@>-J6}8Ab&QXp3 zDkEK_IrxW2B$8FWAbM&n(h>cg>1k#Xdq~#OocbANGblvMKz`Q%}(D zDLW+#-}Z;$uM)|JbY-}*)`x5#70WMqJCNR+uM4(oU!iFknsB?xnt*Q~FpG;KdON(} zI(kGp77v1#ZXZ$L(BtA^`=5|nX{!hLFFsD$u;pkXf z3_7qZEGKwhAIN#?L@K7}BD00M=*ekibTttHw`mHr)RvN#Vr%}KUw^Ty#u!NcRgb7g z5sLezP6s@1MJ5k7BIP}vAZm5x->5#9=)y|U>^BNbjF%DVqpD2)gog}z*O7?gOBGR> zL=I$zXA|95j_`V6KbkEa0uv-pc{DMjPq@lx-3@X zawV?PGyX8)Oa@B1Zw#L12P7fR57C-KN#yJiQ+Ry)JF#oZmhfKT%yKTj-$ zq_1)4P02{|an~2#%lJ1sa;F1@tx!Wr-_9UU#Vqtqb1wZhFce)Z(5Jfk{mGHO#^4w3 z1i>Sc#AUf#pl_ljy`ne>e9Y>J^sQMPGIR4MTlN$nl_7b^Sy2|^?rV_!q(>=286XIlP`fn~aZ~3~^udQKK*lO6eU*EIJN}hG)s1d)eUZ8J>GS zK?dr-oF=QyKOlu2Gs!?f63eAlJ zVxKSv6=}aAU+c!AO&V$FX_PF?d5X}xwgWIK`vHHY;5uKtMVD$y4kNpNHEGCobJ+93 z(u!6k-9pt@CG^DJeq@H(B+_x;d_#|xf!$gj{Imy)SvQulzgUKk)FEn# zE9iWc7m|dag*3jV1l?8IArTGvMBb=7llQ7YB-|uf(sK0-I=)d?}M?w*yG^ za@}ER%t(lfM&wjoD*RDbC8e9f0hU*xbH!<(dc*;yzS#y7c}vjm^nmof7I1a)J&=@X zLEGd=7<}w23hQ?Zj9TWv(QO&vXnqj1&Yp(WKnoanu?3FMJY-R6fGZxU!upkVc)#Wm zIH0JC^Ad_6vBqAC=euj+0hciD^L;3;Y|+EvkGJEKv!>!t6Hee17dvct_#$pU&>OG% z@CMK68wgHK@9@IvCNjJ7IZiyN$9`B`!j6GnY@u2-UaphO4De_?S@tCRdT=1t-jl>K zw#|o2KaQ}%y91GqV=$Y%z>+L?-os|LEJQFDyf#5D$&fQ3q;P0&)XkdK<-w&K!ZYR$<&j^==s~FbaBfG=y-jC{%G$D zrw@OihPSJbbFDg?ajrqUxwC_^9Wk`hT!%g0(nL*6lv&kVRra{NoQ}@ZXDbqakoFWq zc0O8$?i)XtiTpL`y5rhx`;p$OS5QOKv7j(0lO_unz+q-FeUT6ivqLV^@%d@s z(hx-rEQ?`7(^1MMJlc8y{-U#d|E+@L(e`UZLZ2V zNJT{b%FOVPYZ_$dAX98qY{V-}Q^T8#h9S3=de}^7HEL)ah>Nv;iC@?l;S8fosC=6a z9+nx2OmcK^Tv`#j_s#^LTR9TUmmA^}59YuH6&pM<{32|~u*0L%ufw^I2DnB^0Y5(i zxVS)spN_Y*#0xC?VeF5) zEn*5jF-n5XGrV!z<&n5NYc;+QVT+sncicUTz=G_O3aKb=r=z_9}0DG%lN6or>}0mw9Z@u2xXf%wf0J--Mdb zELQpE2u!TXWN5=+aEeW0)@Ki)=wtD0#O@7f{NM~0rl>%}4bQSGS>e=k(J6MSw>&$a zc#28Q>(p5C%)_kq{aDsfbATyp&tyy9EMVJbEn#{4UD&jZ8`zJ+foy|q09#bmNo@)P zm`1Ow^v!WUR`d~BDS3Qep?Ho4f&mt(%ozJc|tcQOu zj%1y;i(t%|k!k7mLH_Rw)0Wj-$hqK!2({p7dFS_1`ID z?xG4V9?}MrB_b>u@Cstym|&Ci8!&u`8kX@m1DV5&@ZA0b;qD$ItRk3z^CkxLq7~lwTQknQWL+gA3$>C46)%5E4pce8us13pPukiz(it%PPyLRh8o@bGs+>mEI=(|;Qy6{ix)wgm`cv=o!C-bFkh;q`fccy}+JE&ps7++l zGW#jAq=eEfvyodnrtPaR9M`@=bELt@Xrn)sh8U_q!JxgA%bo_aV?(HG?c%(-YbMZ^ z4qcSEL7!f{b&eR;O{BQ)i^Rgyibi~ok=)FBO&&WOLSyGoC55F0=xlv9`L$pZzbL&x za(LJoep=XWa#<9?Pu-eFQjQi$%m;~Rvh*e~6Kj&CX+ETC)Q7y%oDxaL&7o3>pR$i6 z>DE3pbjB%k%&So%|L!buQj$na=J`t!O3vp^3M@qrN@Ma$ltU#W_L`IC@eCEnWs{XR z2BC4e(PFY;D3VXxCD~aXjvmy$;1{jWLq3CdNt{e}p`Ra0CAs}nVOz`qG8aW6Wq)r{ zI$Q@_>nBLuB=(^5+_E+IQRpHR6LuJ#=zR#K7ip2V;RAAeuvm3txt$Y{^Iv5_16zBr zSa)N&{uj$Atq62(MmExtqsVVah9oRY57d+Vh;D8c8hAQF5}7UrSC4S?@SY50RYj0d z4$iQx`V4uz?kV!t@*t*@mB1~067lD{wkgIF`0)$-LT$j&?1IY8XrrB&HUq}-nR*@s~D4);qydNxVsO3){2THC9}mQ)!wM| z&=NFtU%q5Uv>viKSBZMfi$mpNb!gZai>j^-f^GU&`21O_P=8ef1?U-S`&SKpNR|UL zuNujf3&Y@_6+?(rq+EnXc}9`x2A1%;?jN%rek7#*9_ko>o~&7ukJgJA8BFzH ziE$L^RBJ`MWY&?ku9kZ=wIzE7&q1yg;e_i5L1Fiv@Q*WIpgz}v$QGwY^vExSxaX_G zttWQ;tAfF>SF{72ka2<+y*l`e_*cm9t%6hnmh!MI^QZWB?Fg_79Kc8Kdy8ab!z4u$ zS0S6!X++Y+48Cf%g}BtMSrmjDGW#ZE-&X#21lTm>e-G_ z{+A`%>oa)U3GrvdwV~A2anW3fpp$za||Ufua>#^P*l*bRS!x5pMgDK~bcms%E5E z&KXIx&TT-GH52&4NAn~zJtaupHx1o|xyWt#W;El~2z2jWog_T|Bj4A-4vk);ho0!Y z1XK^N!Q z@#U(AkhkttG`Fj2?|l13^7UvcdUNYF3R1m_WOj6*RkQb@N@*;5eBu(S^H75?O^UEF zNCfPP9z6M^4ONcE(3~;v(AB}wsP_m97$(yXuG=d?Ny;urZC8N-^Aw@)R42IRIS#V9 zY7}qf2B8rqQfT_*4{t|!gT>8b5HZXaJg3EjVOlyee;fxN58XiW+2OFaYA?z;cnfqZ zs*q?0j za|(nmEMB`qYkRQRc4I00i)Cd=6#Q!zLNX>Fgq&k`bhOJp$hl%jt((H2VwV#&Sh)Z! z?ysWzC%;B~sg(No?n8m`LDX4f!F#3eq(25+&b!i2kn1EqJ15C zkHsz|OWG%#JPSR?>;3x3Cs^yCg$uTmxLL($@v?xtZwo%5)w;XLe4!6`+ME;*(H;%g z)2dL|tohI{=?_|Q-3uBD3?a>PE$Gi31CMMbKx5n*AcZSotF_C71!4;cT#oAxQt_S?;HSi)z?*r{C$(ZAwrBI^=+Ifc z&Dc_d2V|S`VCMOA1kEep*xAGZ^zt=3HgTpm{nuwGn~=DbKG`{piQXQfdxu)GoVqx= zXPTuYyB(QIqXLJqErGA-Bn<~BHkparwl2KRL zvd=o)z0=28@_I{dXOd z);VB5Z$4M%FdA=GTE)@5G1#@wdTw=fDgKkRhEpk%LN;cmHEaG#@>b2Uw)IlBQ1 zI92b3Txru3ZZX@?XO0Yb;n{%tWEchh+!<|`hT^R6v0Jp7gh4B0GLu~u=sqnJV3P09=E=Zo5)9Uxn zgaxDav))yYg|M41nTN|=VPdSYPp^=sey=H4;k1#HZXE8&t?W0KJE1#? z)7Nn3nnj*m-8XYCXYFdvUA7l@%)pmxIC~%O6z$|H{7&PHf_>azcTep8N6Mwv3t-sk z%Y9z*h#<0+^Pg5s2kWop9&DM;^n4a@jWrQWrO=J*_q~km%OAzf^8U&i)onP{uYH6< zWo<4hsJ~QLaiR^M2sIWaYTUq;g@c8V*l0Z6!&>lqG7$@{UE{>Z(9qvP2z+LXHrwk6 z{cf02jZG@TmDLaF5d4|l+tH8pQ7UHx(`4A}&uMJJ#FzA7zcAL&c8JJ+C= zVWRW-l24nD4W+XU%Inz-#DKCLgDWSDib+I<8Gy+@r;zTg%m) zKP6pQ{yeZd(1XRX8w>r5E6$``Y-S`F;=+f8iD`g^P}Hu2R z9?v?;)bRS?No;YPF1~#A2Xz~89kyJ|=f70upnzT`uxx;`lsZapqA%_OS(IT*XU2|2 z8Sj4ZE00|zBQ!VBkmeP6iw0)V$Ll?yW7rF7II;$YMs(1=s}cTQ@{7VKe{7wq#723f zV&8$w>54^f@r90w^t6KvxAAusQt#};T`x(1Tm4PAj$fKsrME7Z*ldAktvBP|I&H@x zUY5q(@1SJdYOTf1(q;IHabNBz+hNp$Mcj?$3U96UUuhY)D8i#hi=UY+CWU>)gH2FO;xfIP#J(W-&qhMCI zaRmE%+K;slJHpys_c77wTz1oC2sgZ8L#PwraI5FGEe;q=zcg72e?Sfegj zh~4~Ct49m!){@VU)%Z^fZaJ9PqXoLPNa!?NE1Y}YCVb7d5N7E0$~XOZ zh&3ei%^z}61FL!U%im*V#hsGwRmoqU63Z=W|0XDVRB$Ja9|-fTe{mNZP6+F_YdYB< zaTcb_7&}!3gt0qS7EU2@zIbu_K&L(G4%}h40ZySIhq;3)s!kfNgd6DH!d=_-m=lrP zT;{b#Zrz7?uG-~0R}->~6Y4s-Uj6!WM$_9lamGPBV`~$qyYL{A>g<2VjhL{K8OlB6 zGBV5Anu04_i=3|DrjpJr-#<}kIT*t&v|1`Gy({HD8Tkogj?Cgp4+jZrlx(=|HzS0^ z?Vs>!!!Y4uLkjjzh!CD^8-TMW9}z@nlF_R5!GgxP$F$PfPxyS>hXssTFWmlekS)rZ zEhG)hWzQ9zrNZiU517Rp3n6~WE4KGjKjCHY7Z$vtnRTE}cITf=_SEeYTemTg6B2k_b~lB!L)3{@l4=s$1XK&&z$-ghp-h!+jy zp(YQWQdxgVky`=`TH{aD6Qf{bTb?9keg%l*>qySTFOYRfol08%fP;Mt9}xTiq%oSj z+lG9of3%F~82o~l*HmE1=7;bpawxhZ6`(S+MsnQa90Z00A$mO++>2_^l+W27 zd?2%lvcP-$ZelOGPEn(Cd{|AGIrdGP%kHm}$1b5qS-tBskUJI30=2WD)-aRhjm(D) zKXTaqoL8`|;Sn2S-W#u2*TUorztO?>>)2Z9vpeMTCBkl9By@D@P4-W#E&EJjnf+5W zc40{#OIo&oMVlXGkuyx$T(>h!*M12L|Lx9FH?C#j^{ZIKyLIfizc=&e6IjgD5T@98 zndS42?1n-qi)a|dtRHL@G-~CA^TX2xuncAj*JcVMMvQ`v!L~waTo9I4-n9^v_55(v zje7RQ!2|c+9nM}Ji^OqP)dl;KLi~K-Gp2YrA5Vr=tolnWo?GyMt-;0EeYGr88}S(r zi(kiVdnoYuhSP|Mfh z-(O|9B~kKHuIc_0TpI8IC+fY#i!<)wi-|w6X#I2C`=>0IZrUF^)*QoI>xW@ur+%D# zryO=q|BioY=0d@ndi>_2EQGnf$J_H=Bu#aOT-z}@>=|_dZ?oCPMh`B**;-rK_$x>8 z(xEe$N^&8dADY7&e_7yjla++vi7r^x#_~4virtPuv7ZnnCn%lK9`hP2vL<=ptCdU3Z%h#E~d8qP~J;$36V6bcV40$38MeI!;(Sq@K|!o3NvS&vl|U| zzD7LP?grC7uh9Af>5{ZgH}vGdNtE{3mJI6k1AP)1QH^D%`D3>~AgP>Mi^OAHGjd7W zhn6mMM?F~<{qW&^a8o8Xrh%ODvTjVTTKNohs{!2Sg+Nk;;7fn);JHaGbm0WOqkv zAg?04N6WZwEopzXEW33O4b)5~6(@p;MU#ZiJyz^kEm=gnViHt#bu6KL4cc|EA8#L& zB8eX41^2gpmjutxMUyjQ(4~ZRFn-Vq_?F!dY?id6&gT(G(JcUF?{p&9D$L>LZAF+W zt>#g5{|k~Qs-GmmA)k@YKM^qfdxGQj*v06Mqeb3sYY%u>u?Ct0`a!glF+V#y5+=9@ zNj`pm#^0)ZDlyZ)hAw*UCx*xbOh*173+rQHe0>~hPo4l3YgUoEBnd7>aUB+Uant?{3i6LSy>fGsmz^@TX7kEH;wZHo zPg2v$B|8mokkOX9(BQC`&X`yG_XoI3x0Xx)AFZj9>$9W>%lK|AseiHDKeG+_SA8Y! zd&<#+ZHL51^J|db_{lVFbARN~5kbtC$kVe+5b#-yJs9Qy3Yi2g@-6W6!mtUg(>iPVTmG8s`(if)mtpY&k z11_O}57NyO7th1?|oIPTsg30K>=+lAR0p!t@#yC|h<11Yb>LuyGv3Po4+)2?p4x zQUP^7z6354zMzup`{7561oqWEhnl0sFfZ~j1V<=9aSDOQC70pR90#257Ybg@BK+&z zR`5SP7{9tAkEir=km9?e8sUJp6OQW8z**&1_`}VOc)=$v>@ev#c3I<%lQ!jG&lxsY zkKDs0ZL=YN*JB*tYDn|GRO82Y%~;dmbiZk&g?wFa<{4C>Bfm{ zz_7z076-8G_zZ;JZe_;98_;aLfPH8!B&|)Bb6Hw$6B$c~Fw^$wbn$0LwoZD~zNfTI z=*E)y|Ir%Wk@)vunb?gbZyJ50Ba{z6x}-Xr{JYo_=;nI5;jOmDr} zK^+}=n&=ZvE2c2|kCP)Us(VXSE$rw$1zFm5UyGic=|&5+L+GEx$#ho;M{KM&(DjP~ z$#)+&Y9|#d(&6^9H1M_&?Q~v78UwW8=<)N&@#Ii`!LH5KUz+0^&e1;X?o0asy(&arNKJ=i^-#k zX8NY=HOZeO$56^?vQ%y)JMyHNm(J3ALMK^9q4i(1*`9Ba=;#%Jp2}GSc8~thct>{_ zD$-zErz6NqETb|z6JYq{8`SFEFpx~hqDRKcLcHk}+UoHyXny}n^EaP_UXvrKRmnAY zQ0-5ZlPX|Op&z~Rwg%RiZ>1+*RfBQi0eZc63w%y;qDxQqk>WkiGD-R(1^g>u5RJTH zhC4FD2)}q?1fM0Rm)I(IFq}BLp1Q5^ZRk-hbKTal#^{Y=nISI>}30Wvr)LM*gk-3aiaO=N*_| z4SlbLlfdzk$Q#ytc9NPg2HU;G84}Cnm)rIKW48dDp`jV=l`uOU?R%AD~ xKkjW8j2bNr@X_5}?Lu>ZtTDq4d}aC@;{BmZ!SSXUzT*|G;rOl9|2t-ff)odf7r6RFov`z4tg7O=&67kalUOjq`d*DpHY_Xb6e6 zN>hI4`}@!zU9NNA*Cp3|9@qPR-|zSHURfub-Y9oQahJTbREzZET$Np4q(w_V$S6oj zNy#YXNIf-`(v^~y`XOCzDJ8YYS8`NJ?ScdUtZ5Ryba&u>H{~EW)1L1?)q=oAJDwSA z1*_fcc=^R1aDPcOEAO5O2YjPgZgc`T9vseGkOEKahw(1e?VvO}lAA7Mfy}fBj@xt; za!UuZRljo}FB&k2-=53^t!d%x<5dh*QUf?(+&yrY4(0EmRj|UYKbst?g{8HDywdm` zTwCVPnw4ZqUJ$ zP!AT^8KV6GS6*mhitElgaq@Rd6#W=#&qX)vu!olo`>b@v^){B=bk!ZBYfO1yVK01A zY{Xm4J#o6FJ}Y_)v8`H*zh(I1$HVIUS{#U(*p07bgrJeEB6Ddt8kotl>#rebIJu4H zd>@8BeZNwqXav5kZKgAuV{uaNTAFMYk6OJRlk2AOB7E(8hw{cv#;1F((O%(nTwQgE zzHFO?Lvl{gZ_Ro5C21dJMlM7XwRB23vjm4*ttXj=<><|;>`BJ)8+A#)VHNHk@I$<$Wi?*cEf%-huf;VU8^!Cw)?wT*Tk$Ma+x1xc zE~ubi<$An6HdQbzI|a+$X|0l$h>(?tF#CTIEz%Ej`_)RY_}n!e(uu{q1IyiiSeE<| z3NO#!fN@5phm~ccD%ED6dxRoP=fmC#qnE@};Qt?UK z8fe&;id~0hg3F;)yrz`{c512URhJL@=WM`B+IQeZX$lS(?R*ZN1}Ruz@)^i{Jw|Mn zLc=3#@!KLLymxF3ZhfqQDsxujmy-s#TzM6CzhHrT|0JTaz!BxF5>P{@2P%(Wfm42Y zqVljMcuDMwf2-!B%-9grSUwA%t{;p!epAt1JsPt|Ou(wJI2^n`4$rNeh#QBEKvBTh z85rvjiDS3U$L^W~aHRG!G=xA@3QNFs=|Z&snv7@H^hTQzYq2TN1zTfNFv7wH=SFYD z&E1SKu4pq#Ki9&tfoXUsSQ)=f-i|TzWN_hy9q3;F8K#IbG1KTde014`ixo;CsckoE zo<9#QHd)x`$6gVXJMKm0vnep-b~c7)O@&W!`|wf#LR;HD+_OvzYU1}}Q142i>yrIA zw0@~D``&)+J@c%fKyuu5^ZtU>*Z1M4ih1Iz;rlSEhQ*^H8<$lpQsCb#6lA;7qxd~| zGhqm+Hta$Thb3h3A`^4Yrjlyw4%~OZb}xO;*p7~S&(I{rGz^=^G~lq#)lMuH9at)J z2R2BsRM#3gc4D#Yz*6=P%Z&b;G4Odg6^~EF@T^*L+qoVMt6ON|^fg%9OPc4!C*#4n zN-XeCz;o9%Sn=Uf3_7dFPo~YsVcO<=dgu(4GO^>lk&|%RE?177GY0*R_2wI)Bk_K- zH(wAviNsrxzTB-b94{sW@^IH++~6O|{ZILz(zgM;>$xZD4jRP0JbGfoiwHh5)fsz! zh~yMm8$8%&7>lNwVsmX24|mhU36)WNc#S%yw?=WsudevIW(0?&%A(@&5xjfEcZlsa zl8>9bh1;1US(sZ52jpW|bjPy{s_(||9>**2(|r=B-phgL_mkN8N*2(uN&L}mE37^} zkqw(x!H2MktnM=p7I;nIs~#g@h|UDgUgraCqsDW)r9ISCj$`GY>Y%o7EX#?%3RN89 zx#;~(p~kQ=d~C-~VWveK&zL=0_%bz?SEZ;6{q;wQIC=j9!Q1sC`Fn|Lei4jdv!;s$ zr{kk|sI8f}*R5e3b#{W-?NlV&?@kjJR}Nu6my2Tmu)%z=_XF`$=Yjm=!Dq4F{sBDb zZdV#93}d^;dUW?)f8KS{mYfuV`Q-eb^!-5qYipQVncVH>~ho$Z0DoV`k%QEpB zXhl+Q{$-d(i+}ZC@AH||WZ=PurF&^dfh+I+a)^?~J9F#9<1{hUk*ihCl9ZJ_tH)iS zJ2^HSU6enyomfhj)3oCm}f)8#cLd{*?Kluo}l@2JF3NFQXbMl z(%{71)wFoGDtD`XNh?E?d7R!Gy1%0f=V!d5xf|qIQTYokSCMAx-aqKmx8Kww{tp!- zd?WR5Qk-`11DzsSzWk(-&MlDV16Hr-SaBB~s#Z-eb-MDeqz5GGS)|Of&)=cOmnyvH z^$mKHqsH?GFdgo#!J+zB$*@k7X9VPuuaXwuEI&cNK$|aLJxt4I>hRni+4NrY0tgE+;ObqmSy+tbN$Q$rG(>0_z*?_O`N=p7_z&1bU(OIz}Pp_It_kE0bncoNz z$=xwx-~C}!=4;H65keaE-I%i+-6&sd!ZTl((eEjy>@rh>vRh5LuCELgUN&Q6-v)8a zWOJTA>xS4+)q)R(9Tsm8S@7$Ewc@`Tmi(#5P_ezAB{%BpijxB@_+=L^kbQ5??@G)H zCMTG4MEzRtd~0*QVrsit@I2d$XA1ucX7@Ma%#41*Zs|RzbYijTz;Z8l>HGi9S^f^a z-igJw1Iw*{Sn8gc@>%_b!q5Oy4p^QkT(-xA*9p!G4{4gPhSz=J`N_s?lmA8d@s<%g zo>PR^LL)vYr4N1{hWyCT1{P13T(ZhuFq8E;S|bq7NbdZT?S?^hiXK0GJ0A2zTDn|T zITzxib$HJ61d#fw&7$(>bpKJ23FNa{DgC=i2 za{|?ro%n z>~U??GTM003ENd>k$#37T4ao&9$kB2x3A%}`b2L$H&{UJoBLs6o(n0L2rzt>DSh)p zJU&T-!eV{#p~o-r>R^BLoc2VVv^o&Y^v{c5nD@t_qt}bCueJ@vO&)$?)fofO(B*G| zooFEL;~(*zSnN8m+{s;fT7u=%w*5(+SnNBn-1vtjb=Q&tujPaAV4{PdN74}7Q}aS# zd_Dp-YC?n~eTU+^G3$iaT8H9}m6wD~BZuMCqNl=1cZQ+pnm$CPhvAo@J;5+(0QR#C z1Ks#A>?=13&J={<1l_g%Nj{4B z%e|Lm(xi$i<2+D*V0Tmyy5POHy6B?ifPJEk@Wf_o6s>eLNB00Tbb4crGY%P{-cSe3 zYtX^3b6n7Tv<80G^1$km%Bc6D7ydGnN5@&7I7HYE*D3|*zOn_vHu>PSbq(OD7=Zc< zAH$n(A^b|&rh9(o!1BBktzFN4MgG}`E77ZVK^$ft`@=8UeUOG z*laMFHWH1Thr^hoF}O@Y2x)6$@!1h8(Azc|jY7IX(}_4-AZQYHvmS%<-SULm4r4HV zf2z^ zAG;kF&vuK#lS6E)#482EaP50}YHo=@j~ON;KkaZ{Cl-eeEVpyFFOp?=Fb?WNwCQjV-gYad_2U9j;9NqJM8*O5QtJ+F|LKcg zLn`RnO2n08D(TKF0d~)SL_6I)QGDbnjf39!a{DvV4s^%q^Iy^hDQ9fbuBRX)dz{hU zK>Z`F@mpFmMg1{D+1QWdon?gk*LLNH%^v~>;8b8Y!@~S`wCf)6gl0j8Ah9QV?p;?cw(W(MeR=@E?$$HMCD*NSDR<# z-h{gw^w`6J;mblp-Z%d?&_2ny3B^z$Ys3p}B*WVqV_xTa4%WUg;n6RSg3Ea`e!hP% zxDK}9-0R!n&LB$>D-~=3Ikw`6#Ki)8iq~ zm~GEp!+hb=Vh6se?+!K2j@(pi4#p8qZ1PM4lomMi>8~VX1*3KKxeK9EfHSy zbz!+LCxj}OUD)8kW}$tFE035mUU=}htsB<|_YrD&xbuV`KLke;UHNstcE3(6P90cE za*te+UBX^I|qzW}hj`lL9?oaHwZMQ8YZLs71w_NGeF&lmp z>`6bJtywE8h+OTgc}ngeDhaUSRD%&TEy;rY4dZG20W&TbJC*!yn(#o&`IL0kh~Gpn zrypp*^1G8rtET~9D_l$G5_DOVtCmW#PTIWj!B$c~pvkATGRa9zovjaK(b^0Z9{lqV zY1Vb+b@PwWrbSAuc^ow~2NO(%Z+As@#| z+TG(Py^4B7P0zlPRa*_+xcrG;d(@MM#(SC_-%K_G-->Aal20VkX(Dy4RvOT=k?!pL zO{M$lX?9FIl@z|9o=ap{oc4?^Wy|q$t7_V^M}ae=s_1mNA`5~ZQOXr1HZXla2ZNQ_ zxu}BP1a)Jhn|EmG9#uAbev34Isj72=~Ld)+UhONomgBtu-wco$dh0RO4!%E6N_sH7XF9j;K;r_?r%N)%=Kj1 zwC5xqBw)Rnk7>&kU{Akt@>uM{jX@<;TIt8CTu2Taz*P}fX!@%lZWwfymR#@8N9vA} zMnx#M7-!RwCqHR-S7-7)^_#wxn$vsHtv0G2p-Y_9PA-F$$#jnt2mfsstHen2X0JD5 zT}v4bpI9y~IU&QZ)h~;831m52Zm+nqM3xs$SuGyfTaJ5w93j@slw<7`w&JKKa%>;o zSg_Ako~>&V3*u0oR}N0d|2$Bh*LCYISQaJEflp2g3IpW1X}F3|w9P@DixW|(ts>7i zRTc;XF3WL*Q@U{T964U=d`37f;W$)`Nswo5c^28 zm$U)=7%9a;s&>#b;xFZy_5$h1KXmAk58RgdMUHR6p;yLt$~B3BX?b6%i`67BH)tV8 znFS(ntNK8_OIN~yX>ZBFY7J~T(MT0bQ{kXOJ$)LL2DO`B((b4X=vQ7t1yXw<&$EiE zJP$(Gfe&a&YYq%?l6(!%PDA0BQc@4U0Kt=Q(2E~eVdlp|GV>-FFgl-le7O!`zb;a1 zTqzWKou&RG?}GC26ZAgXwh{!Z4^y)I6UhIWP4hjfVRHHzw@xf>9axHUD@lSSIniT5 zCl>b(EaHDyCMNBow$vAJY|D1qRay`8>^9MjNsSQKvW{|n-$I6S5*@314+{O3P>4kf zoSi(AW=;A6Uxvle{@CxZ>GDt-lk*eQpY^AV?Z2V#cyG#n(FQA&-KcS;6dn=nvm(!J z(zxx04$VF*gB59A$)$%Z_MO=#E?1F5(;m;on*kNYvd;K&_0w7&8uUs+QQ=UQcYFPbBVW?%OS zx(CbQ(^0ZQ%~!H0($Dr39udo;)sb<+8;Y{HYtL5Uwp%i2P<~vPCHccvOu8=I(nlJ< z{dgu+uam;Pl`X<~*6r|aumU)J`VH<;ny{_QFYxJa3WamNgN(=#6yv^udtpDg{_Hcz zxcEas?ME;X4Tg2Y-@)SAk?=O42`+Y>48wmnh~P|@xv*zNEx4JBV64Y;7@@riq{6C! z3^sts`4KcVZiX@D_hDk(4)A(Y2K_yEgS=%4{EXZOFYB(u{)LBN{XqhYDJLLg%~e<) zbq0*fEm;kOh0u-(QgYx>>m4@gI=idsi4=`WMPo&k1|DN@0w;s!)?KpL9#~wk z>AEaFy=f@!@mv;{4<8`D+aQZSY7@l??`843$6j&U4arnxUcOjV6NoL4n73s}cY1Giup~Xj}@r$_)Mb}E9?>7&66VeVJeSL_#{RQc~a0;988?xo2 zDX{P-7`Ke4(F49qo|NX%nxEew!*(SlD1U*oeOFUENUnr_DydC)5B>jcrEw>kVbP{c z5nY(l2mx~0G{vnR)GZECRrU*LD9a%~tbu2H&(eCXg2HQ;$mez?Y*)#rOveg1eXfuy z9+p8v_)U5?_7+^fR7P*^6@iz`eM&h|0Gkd~Qj^aWaJR0aTjS1ya?LZkm~sZ@nAB4G zrW~*dcuhkB4?$44?OU34YA;w$`9RG9JE3fD3ms@!*4&AuX9t$T-1eIiEcLDi3p=s& z>cDdKAC^tc+h97h()_@U&^7QI&GK9et!uv1;)Dcvy!;mxzgi68EB{bq&*_k_CB^#= zjR7;sK%L_{9I8Ae52Hgu;pP6A!zx z>EMatwX)KTHl^an>)%jemJT56$I=RNCZ4D<@Wy#{{HIOoi98J$lhzpW{J>_yrw=tp~VZc^s` zo-|`bAstb5Bk{*PQk&vHu=xVPU<=wClS>B+^+|c@IlB8@mtJdKq_RsEv}^8lT0O>x zM0wBXjq7x}a_bkl<*uW=F^VD{5u8DJQY!55XdCI5%k%h$`{>>VMZUY^6qSW3@uAks zbYj0E`zKr{+s%>{rqg%ngpmS2t$svx&t-V!0RQweg9RryA92?exj@^{x8G&xp>m4dF3-2elA zaJGPK7n^YG>Pu9#*n-a(oTW{AHY|57hXy&?@o<%Ww5Pz4+XiM*hN3HPaM(f@{5`nw z&RS|d+>0;u7m=@s`|y$p)9B_}>neRs1Y=DpKF{X!oP_i1U zNZK{XYaTU;UyktMHB+vMOCI}kgLJxh$o>E>T{A*FaCjgaA5s(>rUkO^oc;wTw+Hg| zGrzn|mIw0CwATV3+d$Sy^byLc2lB_8YlR})fB=5k^Ng@%i9eeit`d5l_v1-BrQjj^ zvR|bptX%EGx({vO+E`z1YZ8LLq7Tn0jR4s;;3q}nq5PeI+s-Wj25)YWT?H==da~hz zR45DV$Axj}V42#7huq16343~TaJQpS*smvJ(pjiI<-tp*4Qf z&<9u(Xu#G9Um)g?4zF3%3WMHtXW_fw z5EP=$(UNgEI6;N)=*!_W`L0~QNFM)|EAaOsMci;wj$5sj@lmoAzyI0|i&KA+)LUCM zjM(ypW=_|@i>YrZYD9MwegzB)l>RV*-A_7USUahdRXD@RAV}!ZiU5H zRVXmW8com1Q|eVKOkejy{OFQ3-sw^;j(BB_zOMP=h-cQ=SN6C#s6n#MVyK@gUKD79 z3C9MDC%(2p--GtzVRLOz^_-IUh^7tpcN_T;&lW(%o;zuS}!oPv_ajEUjz!P ztZ;kLK%sh?6<)cqP&kP!alCt$@a=X>EV_G5sCCv7=YOse7I(G4oh#dflOoMArR+Z!_EOz@jmAo!b^;56G|;48VL1sf+pPcK7kGgt`U zfz?f6zHv_i^rF5gIS=1``7J)#7EsR^WJ_~S)qZhrN^NtKpn5xpNFRh zRB-dtJh<^e8MkI$2mRejXxOI=iv4XBaE!-8XbF--z4bLP>Wwt|yzTgVz(dRiKQ0R#bqzvKrP|ltTBtnizZZ8ic&pM(e~pSo28_|C~4v zqeUi0SX+4ldb^q8vOx#I)yM+#&341*7;DV>kOp_X>~PDj6tF3Cz-_Y%fP? z^F07(nH?8qKSJC(XSq<6KgSnqQ_X}`Z~QRFcD3L-24L~mH~CY-12O8CoOp0(5IT>G z7B^G`;e%zn#p7-T<5k-`VjcDVI7{lU*u^Xa*O=?kLirG^`0hb^#vypeJCs(JN>-T0 zO`z4^gHdRnKm}33IM!(+P1lyJI33zWrdtDXSwfD8wmu2K{Tf$D4gGP&&SDB);fvYj zmE`pT@$k}@v{tg>)F$*I*fzC6o98&_`^u+?}qTzKBrkAELh!aeKz z^R|4+bWU|J&)U0lcqbO`4lGx4hd+^E$vH7kxf6?^1IxL8Sft+lfB};1K=1rp$v_&z z&G)KcK~_8~Ei8qcl@s~m$V)JzU>di)IS8-a=SX&THbMQ~Ic$D;I%Jg1W4)AKaB}@D z9_8~@*s3s%kIefjgUWckOfJtocaMK7 z9pR+)6_SOv4E`|XHMeCQWzW1%+$-o1_wVSZb~~}(ac~CYq>(xyiF|CpitPJ zxK_kwuaycL+?R7QcPlJNoW?(2suW&T9Kkb;)C-OF2XlI=M&Xe)y|}_#vvAZ#D|V{X zDBL?;o#$`RC``?gJddlZ7Y6v>p`j%zg%9uTCA$QJ!tQIg(Qie=!VHgbwCK5E;oLJO zygT@RgdFHoeAAl-e!*fAd?ZXvrD)|1-f-an_OpKL0z~b(nsk^T4^Ve29MGp14fN zR@N&VXMY7A|I#T;FMk9_(zOe(T>lJrQnd;Xs&&CBf!zzwU(&?BJ+%tko;cy&=*p5# zq}YMV{*(jt`+?fH^V|KHU3bbvke53CtGu*}6KVR%JB^%h+>Gg(aj~+VSm=J>XMPG-v#r0htq{*h@A6T1O6NuRbY|X*Z0QzSstS z5;qkWv*5OD1RuV36x8nzW(TEnAS;?bh-*LO!SvK{ZaPy8gT9AxyWBlE`6z@BH9mp% z>R^5>^9tHd2C`wnTM(@E=WpTPAUf2SXJ-6`Z`#PMMY32A0)9JI5u0v$azfG$AiPP4ADQ!m6z-{#UBfu*s_}yieksx^VH9Fcuv`d4NaY~d6*>+ zz3Yy5C3}a%s(NACN+ULu_r%_}_4tj35cQsEab}P&jz6l-Q-%lP$wl3Gg_u>aoyOf?f&+%EBkvo_ald#e zg~Y5xvF=!Um7jo*Og=wFDivQkOjjiX4#F2uvsF0l^ z7J02h>#;WC>3tQ6o#z z)Fd9Bf%O`ZST1R5zQhc`xjM^m_Uu5Eotc1_mkaTz&MIt}&>PnY*J96sF6b4Qf}dVk zW3}Q&T(sI4jel>(-&eKpu1*@pty0E!x3**M2pPP;We0|iZ-HSeGjVa`b2t;V3*YCL zz~hG9Xt(n`ct>a9?dg)HW@h+ayp);(U#4Z_68&i~aL7Ka8iEk5z8~++)`sBp{rD&4 zp|HGhKUO;~6Q)Z(=QJM`{8+pnZ%sW|@U&_lu6{R1Jo|k%u2@niR&vP3wU%9|y(tSD ziY05XWqUAYW&{oGwi|ypEg)^Joj4^bl_Eawz*i#MEJ_w_$H^B@(|VaSe5}fp)MI~R z=P2Xff#vLfO-{ngK>O#DEl4v!F3q}c!{zXhK(M?Y72W})3FHd zvfCL+X()&Eu|bEzp4K!G*&faF26V;8n@4cKNLd_NI)c>; zzr$d+k$m328Dz3Ya+pF5+$u#+Ce`;4xD{7*?UGw(ROkTf;N9kSrv%t>6= zuoXT$naIu7tHFNKL~cl%3-|RVaA0yY#Q9F(a}RyMSbIFr{A&kSZ;j)C6`GKBdn~&> z`6BFN9M66yi-kwy$8cZw-NMlaMsrQy(ZZ6YvFvbMQ#gF6q^a@$lOX7Nd?c67Uzy+1 zV+0>kyHemVDvAfWnv0(u8^%Li;>043WY_0u;;Riq_|4+;;=mcDdJ9~Pw|$zJ`Y zzC7d22D&<FjTk zKkK2XUZSypm+Loc_=^S88G$}H-_l^WHwq&3IX_lK7->W!574F)6;rU@&Bhul1lJ&fv zYjoM|xuh`i)Z^_()2PK&5`=u-K$d^>*{g9i1fOZ;~V6Z1^i_|#z^XKjbtGk*!e8^## zJ3y0XEji>B~8s~^)9^r>r+^9T8_2eJ%>hp-2$eVOrmApbw3IY8?WX1!_i_V~u{Hj3 zTt$mo?a(c38AW|}LeCE~>DMbaq%Narl0gr&?>&$-KlH}-^FRZ3_QS)woX9s$fLDf_ zQq3Qb)U|446X%PgqQ8sn?ELZhiAUlH)gb(E`m}hZb$>iNb)6XGY{Ss}Ymm5G^#D9| zUt0VrWgxzcH}BSoC8Pt(@&B5dG>yPBomfIUupIn{Wlx}2fw$pc9MarVkg#e9{+iw< zcw-ugN&|)n_ZtsIAKeYYS09E-rUGY$Id_Ml)uAUsxuRj%@LLzI%o>2Bd$_~!z;L`= z6%H}kVJNbi3~N?};k|P!q0l-ED@7U`Vb$hP#EF?;C#iuxemo4no(E&@nOu->2$I~( z2$B~B;=r}H!M1wg+K%&j_uPBx^lC>&$TK95c-EwLyh(MmSf= z0S&Zu@%aE3R9c{crePk~F6n#sE$)rAPv!8DohQ~GZHLj91bD2j1tyO4!DP7x_~IFW z_cfkC?5JSOF)4*f??bWucOIxq!V;;()1Z?$7}t64gWkT82uIW40}sQyzLKU!FDM#w zt!9Jc%8}SsIUM3I#~=?8g4*&}^op^DsrjR^qPZKiN@iLOLCwPRW@B)3;Z)fUx*>93D7XDQLBc!*@KxTmE(|p10UkaAe;oy!hE%tf3Tx!@XCEPnnIt zXy;?%XFZ}&e}SZ_`RzCiJ%SbJr(z_ocQheCyMaxeSi(B69Q&`SnfBD<-_y{54lMis zVUhnb7%`(CSNtRSGl*sW@5RO$ZCX=y&sPevqx{2QSV|(5mN@QPb9bG|R zBM=qU9?*(y0xUlFh{mUS;;CNMr1Ys5-ivrfMK^vazX{;mbX!RfFl0+BE_PZ zJ2Lq5t1S0U`~zk>T{z~$7g&8%k-c-8!1S#$AKO$5iNR{DGqnne?KJsZR5?7|uFYj3 zH(_JC9`Bt&a5&$PSL`T(r;GL3HSIdYp%ELb%7d}l#ymOtEXW)-VR^-4a68J3+kE$e zQJXnmO3MV*jgqFu=$)jgDYW8h(=||DY0V+UD`Ck^8y@$5Iy~(oNvOj|f#O|zUQ|2; zs$bgiNojvrE^v^{^4uX<%aNg{DJ+X~;w*s%D9?1}%Y$X%k;sL6*S{9}rZ{uNlrrH( zFBd+bk|UJgxF@b{b><0Y z>clhpICG>(MT%^$II_pZuH7HZC`!HO=;XJ<66Ewd}ahB6k}2%!V^& zc~Z|nYaah2h@zvddC-w?a;dlEW&Y9heXa!ue~u%MIcD7B#AI6i(S)by&7%jSjkx0O z3QBumz$30Dk>_81-mq{jwYEr_n&pifXp*@$zqye{he|a0c3TEzJW=Dz36iGfunOND zdYGW4E5Cnmlu{ok@vPue^!A1VSGZiH`ZPH{`ZAx&m1S7qd!6=lk>W*XZd1O&nFDUZ341aE^p=JLOBe5um1MVQTjMR7^FfV$R~3`LrUutNE~Ep^8hlo-fbLw^WSPm5rsl@c z9_%>m3xzuO=4~V1(X(fh?{#7s)Pd#De@)GeM`<5Bu?+6OlJyVEoH2d5|C|Qu_tBHH z_dTaXM*$xi|CmnC2HqWBP6J2!a8^tSJ$~WGYRcE>_Q?QFFS$$`^Md$9^%bAs!Vj6F7ngeAH_bBtxX$VfbO(=5_)<@i+>y1~zT(yuQ6AAh@H7|Jj>W+3b#LIo(n+x9tE8#%pF9U_3Tk0tvj`&J*T9VftH9}E z75KNWhvr8QVeyg8P`~6pynCMx-VtR`uzff9WR*bwk^5k!xClm<9EPQmzBDZTINaWF z6>5H+f#{tVU`5IW*k5@XHV?ZBf2SRXL=S>hj}F0oSPw zu^r|IRKS$RjgaP>{%=t(vIEP$|C$<%e&yVWWoQSMo&T`>diDUUVI2(USp~lWlVHux z8dzVs81(91LU-lqkhs1cL|$WHb6X=!6Gno?{bsmR9spP0yoX`q`hi8|Cn$Ax0`uKp zK;UTt+TXrG*>ZKLeEtJ0^JKwD^yU}zc6=+m^7#)$U$`r@eDW8b-8w6DjFm#mjO{`h zEomIobFR?rqBKe^=qEhrC4jiF-SZmO|;ojnm=)qd3U%$^e#nPfSGB=P*Kmp-@*7kX zqiE#0pYX?T0{N?chnC{mlrifY+;Lw?ZHHUn=(*K&R$J24xTR87|Mw6lw~Z7|HADB) zlBOne(rZ}JkVU56_2B4zkdD250q@`DkV<|Hd>eI!?tZRU2U;l|HbQ;HH&X1o2INgw^y$r7!90kq~<=E})0C=@if#*mz*4o+?*$jI?<3(k@I=~L1hO2U*Xs#Y;tXAQC zSp}FLuEwu!Hwq(XN*e9i#X>pBUAB34me6#+1~1DWFPt?=gH?083QtSUk6c}n|5;j@ zD{AA!QF&5a+vk>8^JycU4RWT6(>Ez(W>4Cucb5!W`cl8_M>KknfXx3sB^_lS`m(i( z%I_oPhdw5e<0YiJ^Y>^@jDY;Elu&ebZ<;dp8chjsqnyKe^rMFrJv@Ao!fN`GvCl06 z?_N~kUr61%xlvnb9*xPfC$m@QX)vO-T>Hf0ZE_hD86{PuT-`BMC(NB6VNqJ3TztX&~ zpXlrEw`3FAN+a|dNVokDowRsK;|EA{jMr1r&5`5p*|ra8(d;fBJJORnu@8?C)5Pxr;DiuJTbW0DliRqMd;JJX?`& zH6u63G>h|seR$vPYvST-{;VwAF1~UsfU{?g6sLLyvXY~sI6EtlC+rp#Q+;`a zh8B#R>%+b|*3dW1m!nO5U~@XMi+LoRe+K-j|3o-wBjl!>g|J@i#S5~M;dr7aYc!?8 z4ds5^yfhsiOzOh{FR~!WzBh{_jzIP2o_u}QIVe8h!JcO%P0j1>ZanIJF-)m(;+%Ko z;8ADCUMH$yU%nOJfAR{BXqxlfYj5C`y)oaA-0_Wf>$87m3%pL!VfTIC!CbNerEvZ? zSoBxt@!O=(KvRXgPLsvtS|z?FBaf4Nbz$Y#U9iqcmUjrcVoP^vju@_j>V7{d$V<}H zj6eE?MlaRCS*ma8j=hlUyB1JBEJ?Ys$E#Gaz3vrI6^ z`Y?%Bn_y$uUK(@K6tfp+5=-8Cec7;yDzePcO>|-{9W^mWkNJu8MUqGq+?Y?+$1N~6 zeKKX`nd7OHD00!V#M&!ibn~(Wj=KmHJ;4h5ZtY8f30635wJj|iYlSB1hO~H|6*gT^ zp`epiD6W;E+zZy|C;TQZzif?HEvviATPvOrZ;h|# zhKUU%@y(%EhT=Vv6EW|v7NpF#Mw#|;1@PAzZ;$fvzWLJ%J#X9;*d$xy&Vh@d)wRO*nx(>HLTh|ieNd=kV}*m~<_XOgm}67MbD>{_1!_pE@kfj2kN48)mBJ1(#EUKyCHd|7UoMIg8DfcI4blw{D@RX3+G&rYwd

vo_hN8u)Tn8r|+aJJpFLrUOgne@)HM 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 None: - self.bin_log_path: str = cfg.BIN_LOG_FILE_PATH + self.bin_log_path: str = cfg.BIN_INPUT_FILE_PATH self.baud: int = cfg.DEFAULT_BAUD_RATE @@ -81,7 +81,6 @@ class VNADataAcquisition: # Serial management # --------------------------------------------------------------------- # - def _drain_serial_input(self, ser: serial.Serial) -> None: """Drain any pending bytes from the serial input buffer.""" if not ser: diff --git a/vna_system/core/processing/calibration_processor.py b/vna_system/core/processing/calibration_processor.py new file mode 100644 index 0000000..c946917 --- /dev/null +++ b/vna_system/core/processing/calibration_processor.py @@ -0,0 +1,134 @@ +""" +Calibration Processor Module + +Applies VNA calibrations to sweep data using stored calibration standards. +Supports both S11 and S21 measurement modes with appropriate correction algorithms. +""" + +import numpy as np +from pathlib import Path +from typing import Optional + +from vna_system.core.acquisition.sweep_buffer import SweepData +from vna_system.core.settings.preset_manager import VNAMode +from vna_system.core.settings.calibration_manager import CalibrationSet +from vna_system.core.settings.calibration_manager import CalibrationStandard + + +class CalibrationProcessor: + """ + Processes sweep data by applying VNA calibrations. + + For S11 mode: Uses OSL (Open-Short-Load) calibration + For S21 mode: Uses Through calibration + """ + + def __init__(self): + pass + + def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> np.ndarray: + """ + Apply calibration to sweep data and return corrected complex array. + + Args: + sweep_data: Raw sweep data from VNA + calibration_set: Calibration standards data + + Returns: + Complex array with calibration applied + + Raises: + ValueError: If calibration is incomplete or mode not supported + """ + if not calibration_set.is_complete(): + raise ValueError("Calibration set is incomplete") + + # Convert sweep data to complex array + raw_signal = self._sweep_to_complex_array(sweep_data) + + # Apply calibration based on measurement mode + if calibration_set.preset.mode == VNAMode.S21: + return self._apply_s21_calibration(raw_signal, calibration_set) + elif calibration_set.preset.mode == VNAMode.S11: + return self._apply_s11_calibration(raw_signal, calibration_set) + else: + raise ValueError(f"Unsupported measurement mode: {calibration_set.preset.mode}") + + def _sweep_to_complex_array(self, sweep_data: SweepData) -> np.ndarray: + """Convert SweepData to complex numpy array.""" + complex_data = [] + for real, imag in sweep_data.points: + complex_data.append(complex(real, imag)) + return np.array(complex_data) + + def _apply_s21_calibration(self, raw_signal: np.ndarray, calibration_set: CalibrationSet) -> np.ndarray: + """ + Apply S21 (transmission) calibration using through standard. + + Calibrated_S21 = Raw_Signal / Through_Reference + """ + + # Get through calibration data + through_sweep = calibration_set.standards[CalibrationStandard.THROUGH] + through_reference = self._sweep_to_complex_array(through_sweep) + + # Validate array sizes + if len(raw_signal) != len(through_reference): + raise ValueError("Signal and calibration data have different lengths") + + # Avoid division by zero + through_reference = np.where(through_reference == 0, 1e-12, through_reference) + + # Apply through calibration + calibrated_signal = raw_signal / through_reference + + return calibrated_signal + + def _apply_s11_calibration(self, raw_signal: np.ndarray, calibration_set: CalibrationSet) -> np.ndarray: + """ + Apply S11 (reflection) calibration using OSL (Open-Short-Load) method. + + This implements the standard OSL error correction: + - Ed (Directivity): Load standard + - Es (Source Match): Calculated from Open, Short, Load + - Er (Reflection Tracking): Calculated from Open, Short, Load + + Final correction: S11 = (Raw - Ed) / (Er + Es * (Raw - Ed)) + """ + from vna_system.core.settings.calibration_manager import CalibrationStandard + + # Get calibration standards + open_sweep = calibration_set.standards[CalibrationStandard.OPEN] + short_sweep = calibration_set.standards[CalibrationStandard.SHORT] + load_sweep = calibration_set.standards[CalibrationStandard.LOAD] + + # Convert to complex arrays + open_cal = self._sweep_to_complex_array(open_sweep) + short_cal = self._sweep_to_complex_array(short_sweep) + load_cal = self._sweep_to_complex_array(load_sweep) + + # Validate array sizes + if not (len(raw_signal) == len(open_cal) == len(short_cal) == len(load_cal)): + raise ValueError("Signal and calibration data have different lengths") + + # Calculate error terms + directivity = load_cal.copy() # Ed = Load + + # Source match: Es = (Open + Short - 2*Load) / (Open - Short) + denominator = open_cal - short_cal + denominator = np.where(np.abs(denominator) < 1e-12, 1e-12, denominator) + source_match = (open_cal + short_cal - 2 * load_cal) / denominator + + # Reflection tracking: Er = -2 * (Open - Load) * (Short - Load) / (Open - Short) + reflection_tracking = -2 * (open_cal - load_cal) * (short_cal - load_cal) / denominator + + # Apply OSL correction + corrected_numerator = raw_signal - directivity + corrected_denominator = reflection_tracking + source_match * corrected_numerator + + # Avoid division by zero + corrected_denominator = np.where(np.abs(corrected_denominator) < 1e-12, 1e-12, corrected_denominator) + + calibrated_signal = corrected_numerator / corrected_denominator + + return calibrated_signal diff --git a/vna_system/core/processing/processors/magnitude_plot.py b/vna_system/core/processing/processors/magnitude_plot.py index c7868ab..c83029d 100644 --- a/vna_system/core/processing/processors/magnitude_plot.py +++ b/vna_system/core/processing/processors/magnitude_plot.py @@ -5,7 +5,6 @@ Magnitude plot processor for sweep data. from __future__ import annotations -import os from pathlib import Path from typing import Any, Dict, List, Tuple @@ -17,6 +16,8 @@ import plotly.graph_objects as go from vna_system.core.acquisition.sweep_buffer import SweepData from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult +from vna_system.core.processing.calibration_processor import CalibrationProcessor +import vna_system.core.singletons as singletons class MagnitudePlotProcessor(BaseSweepProcessor): @@ -30,6 +31,10 @@ class MagnitudePlotProcessor(BaseSweepProcessor): self.width = config.get("width", 800) self.height = config.get("height", 600) + # Calibration support + self.apply_calibration = config.get("apply_calibration", True) + self.calibration_processor = CalibrationProcessor() + # Create output directory if it doesn't exist if self.save_image: self.output_dir.mkdir(parents=True, exist_ok=True) @@ -43,18 +48,24 @@ class MagnitudePlotProcessor(BaseSweepProcessor): if not self.should_process(sweep): return None + # Get current calibration from settings manager + current_calibration = singletons.settings_manager.get_current_calibration() + + # Apply calibration if available and enabled + processed_sweep = self._apply_calibration_if_available(sweep, current_calibration) + # Extract magnitude data - magnitude_data = self._extract_magnitude_data(sweep) + magnitude_data = self._extract_magnitude_data(processed_sweep) if not magnitude_data: return None # Create plotly figure for websocket/API consumption - plotly_fig = self._create_plotly_figure(sweep, magnitude_data) + plotly_fig = self._create_plotly_figure(sweep, magnitude_data, current_calibration is not None) # Save image if requested (using matplotlib) file_path = None if self.save_image: - file_path = self._save_matplotlib_image(sweep, magnitude_data) + file_path = self._save_matplotlib_image(sweep, magnitude_data, current_calibration is not None) # Prepare result data result_data = { @@ -62,6 +73,8 @@ class MagnitudePlotProcessor(BaseSweepProcessor): "timestamp": sweep.timestamp, "total_points": sweep.total_points, "magnitude_stats": self._calculate_magnitude_stats(magnitude_data), + "calibration_applied": current_calibration is not None and self.apply_calibration, + "calibration_name": current_calibration.name if current_calibration else None, } return ProcessingResult( @@ -72,6 +85,29 @@ class MagnitudePlotProcessor(BaseSweepProcessor): file_path=str(file_path) if file_path else None, ) + def _apply_calibration_if_available(self, sweep: SweepData, calibration_set) -> SweepData: + """Apply calibration to sweep data if calibration is available and enabled.""" + if not self.apply_calibration or not calibration_set: + return sweep + + try: + # Apply calibration and get corrected complex array + calibrated_complex = self.calibration_processor.apply_calibration(sweep, calibration_set) + + # Convert back to (real, imag) tuples for SweepData + calibrated_points = [(complex_val.real, complex_val.imag) for complex_val in calibrated_complex] + + # Create new SweepData with calibrated points + return SweepData( + sweep_number=sweep.sweep_number, + timestamp=sweep.timestamp, + points=calibrated_points, + total_points=len(calibrated_points) + ) + except Exception as e: + print(f"Failed to apply calibration: {e}") + return sweep + def _extract_magnitude_data(self, sweep: SweepData) -> List[Tuple[float, float]]: """Extract magnitude data from sweep points.""" magnitude_data = [] @@ -80,23 +116,29 @@ class MagnitudePlotProcessor(BaseSweepProcessor): magnitude_data.append((i, magnitude)) return magnitude_data - def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> go.Figure: + def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> go.Figure: """Create plotly figure for magnitude plot.""" indices = [point[0] for point in magnitude_data] magnitudes = [point[1] for point in magnitude_data] fig = go.Figure() + # Choose color based on calibration status + line_color = 'green' if calibrated else 'blue' + trace_name = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)' + fig.add_trace(go.Scatter( x=indices, y=magnitudes, mode='lines', - name='Magnitude', - line=dict(color='blue', width=2) + name=trace_name, + line=dict(color=line_color, width=2) )) + # Add calibration indicator to title + title_suffix = ' (Calibrated)' if calibrated else ' (Raw)' fig.update_layout( - title=f'Magnitude Plot - Sweep #{sweep.sweep_number}', + title=f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}', xaxis_title='Point Index', yaxis_title='Magnitude', width=self.width, @@ -107,10 +149,12 @@ class MagnitudePlotProcessor(BaseSweepProcessor): return fig - def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> Path | None: + def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> Path | None: """Save plot as image file using matplotlib.""" try: - filename = f"magnitude_sweep_{sweep.sweep_number:06d}.{self.image_format}" + # Add calibration indicator to filename + cal_suffix = "_cal" if calibrated else "" + filename = f"magnitude_sweep_{sweep.sweep_number:06d}{cal_suffix}.{self.image_format}" file_path = self.output_dir / filename # Extract data for plotting @@ -119,9 +163,16 @@ class MagnitudePlotProcessor(BaseSweepProcessor): # Create matplotlib figure fig, ax = plt.subplots(figsize=(self.width/100, self.height/100), dpi=100) - ax.plot(indices, magnitudes, 'b-', linewidth=2, label='Magnitude') - ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}') + # Choose color and label based on calibration status + line_color = 'green' if calibrated else 'blue' + label = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)' + + ax.plot(indices, magnitudes, color=line_color, linewidth=2, label=label) + + # Add calibration indicator to title + title_suffix = ' (Calibrated)' if calibrated else ' (Raw)' + ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}') ax.set_xlabel('Point Index') ax.set_ylabel('Magnitude') ax.legend() diff --git a/vna_system/core/settings/calibration_manager.py b/vna_system/core/settings/calibration_manager.py new file mode 100644 index 0000000..9914dce --- /dev/null +++ b/vna_system/core/settings/calibration_manager.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Dict, List + +from vna_system.config import config as cfg +from vna_system.core.acquisition.sweep_buffer import SweepData +from .preset_manager import ConfigPreset, VNAMode, PresetManager + + +class CalibrationStandard(Enum): + OPEN = "open" + SHORT = "short" + LOAD = "load" + THROUGH = "through" + + +class CalibrationSet: + def __init__(self, preset: ConfigPreset, name: str = ""): + self.preset = preset + self.name = name + self.standards: Dict[CalibrationStandard, SweepData] = {} + + def add_standard(self, standard: CalibrationStandard, sweep_data: SweepData): + """Add calibration data for specific standard""" + self.standards[standard] = sweep_data + + def remove_standard(self, standard: CalibrationStandard): + """Remove calibration data for specific standard""" + if standard in self.standards: + del self.standards[standard] + + def has_standard(self, standard: CalibrationStandard) -> bool: + """Check if standard is present in calibration set""" + return standard in self.standards + + def is_complete(self) -> bool: + """Check if all required standards are present""" + required_standards = self._get_required_standards() + return all(std in self.standards for std in required_standards) + + def get_missing_standards(self) -> List[CalibrationStandard]: + """Get list of missing required standards""" + required_standards = self._get_required_standards() + return [std for std in required_standards if std not in self.standards] + + def get_progress(self) -> tuple[int, int]: + """Get calibration progress as (completed, total)""" + required_standards = self._get_required_standards() + completed = len([std for std in required_standards if std in self.standards]) + return completed, len(required_standards) + + def _get_required_standards(self) -> List[CalibrationStandard]: + """Get required calibration standards for preset mode""" + if self.preset.mode == VNAMode.S11: + return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] + elif self.preset.mode == VNAMode.S21: + return [CalibrationStandard.THROUGH] + return [] + + +class CalibrationManager: + def __init__(self, preset_manager: PresetManager, base_dir: Path | None = None): + self.base_dir = Path(base_dir or cfg.BASE_DIR) + self.calibration_dir = self.base_dir / "calibration" + self.current_calibration_symlink = self.calibration_dir / "current_calibration" + + self.calibration_dir.mkdir(parents=True, exist_ok=True) + + # Current working calibration set + self._current_working_set: CalibrationSet | None = None + + # Preset manager for parsing filenames + self.preset_manager = preset_manager + + def start_new_calibration(self, preset: ConfigPreset) -> CalibrationSet: + """Start new calibration set for preset""" + self._current_working_set = CalibrationSet(preset) + return self._current_working_set + + def get_current_working_set(self) -> CalibrationSet | None: + """Get current working calibration set""" + return self._current_working_set + + def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData): + """Add calibration standard to current working set""" + if self._current_working_set is None: + raise RuntimeError("No active calibration set. Call start_new_calibration first.") + + self._current_working_set.add_standard(standard, sweep_data) + + def remove_calibration_standard(self, standard: CalibrationStandard): + """Remove calibration standard from current working set""" + if self._current_working_set is None: + raise RuntimeError("No active calibration set.") + + self._current_working_set.remove_standard(standard) + + def save_calibration_set(self, calibration_name: str) -> CalibrationSet: + """Save current working calibration set to disk""" + if self._current_working_set is None: + raise RuntimeError("No active calibration set to save.") + + if not self._current_working_set.is_complete(): + missing = self._current_working_set.get_missing_standards() + raise ValueError(f"Calibration incomplete. Missing standards: {[s.value for s in missing]}") + + preset = self._current_working_set.preset + preset_dir = self._get_preset_calibration_dir(preset) + calib_dir = preset_dir / calibration_name + calib_dir.mkdir(parents=True, exist_ok=True) + + # Save each standard + for standard, sweep_data in self._current_working_set.standards.items(): + # Save sweep data as JSON + sweep_json = { + 'sweep_number': sweep_data.sweep_number, + 'timestamp': sweep_data.timestamp, + 'points': sweep_data.points, + 'total_points': sweep_data.total_points + } + + file_path = calib_dir / f"{standard.value}.json" + with open(file_path, 'w') as f: + json.dump(sweep_json, f, indent=2) + + # Save metadata for each standard + metadata = { + 'preset': { + 'filename': preset.filename, + 'mode': preset.mode.value, + 'start_freq': preset.start_freq, + 'stop_freq': preset.stop_freq, + 'points': preset.points, + 'bandwidth': preset.bandwidth + }, + 'calibration_name': calibration_name, + 'standard': standard.value, + 'sweep_number': sweep_data.sweep_number, + 'sweep_timestamp': sweep_data.timestamp, + 'created_timestamp': datetime.now().isoformat(), + 'total_points': sweep_data.total_points + } + + metadata_path = calib_dir / f"{standard.value}_metadata.json" + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + # Save calibration set metadata + set_metadata = { + 'preset': { + 'filename': preset.filename, + 'mode': preset.mode.value, + 'start_freq': preset.start_freq, + 'stop_freq': preset.stop_freq, + 'points': preset.points, + 'bandwidth': preset.bandwidth + }, + 'calibration_name': calibration_name, + 'standards': [std.value for std in self._current_working_set.standards.keys()], + 'created_timestamp': datetime.now().isoformat(), + 'is_complete': True + } + + set_metadata_path = calib_dir / "calibration_info.json" + with open(set_metadata_path, 'w') as f: + json.dump(set_metadata, f, indent=2) + + # Set name for the working set + self._current_working_set.name = calibration_name + + return self._current_working_set + + def load_calibration_set(self, preset: ConfigPreset, calibration_name: str) -> CalibrationSet: + """Load existing calibration set from disk""" + preset_dir = self._get_preset_calibration_dir(preset) + calib_dir = preset_dir / calibration_name + + if not calib_dir.exists(): + raise FileNotFoundError(f"Calibration not found: {calibration_name}") + + calib_set = CalibrationSet(preset, calibration_name) + + # Load all standard files + for standard in CalibrationStandard: + file_path = calib_dir / f"{standard.value}.json" + if file_path.exists(): + with open(file_path, 'r') as f: + sweep_json = json.load(f) + + sweep_data = SweepData( + sweep_number=sweep_json['sweep_number'], + timestamp=sweep_json['timestamp'], + points=sweep_json['points'], + total_points=sweep_json['total_points'] + ) + + calib_set.add_standard(standard, sweep_data) + + return calib_set + + def get_available_calibrations(self, preset: ConfigPreset) -> List[str]: + """Get list of available calibration sets for preset""" + preset_dir = self._get_preset_calibration_dir(preset) + + if not preset_dir.exists(): + return [] + + calibrations = [] + for item in preset_dir.iterdir(): + if item.is_dir(): + calibrations.append(item.name) + + return sorted(calibrations) + + def get_calibration_info(self, preset: ConfigPreset, calibration_name: str) -> Dict: + """Get information about specific calibration""" + preset_dir = self._get_preset_calibration_dir(preset) + calib_dir = preset_dir / calibration_name + info_file = calib_dir / "calibration_info.json" + + if info_file.exists(): + with open(info_file, 'r') as f: + return json.load(f) + + # Fallback: scan files + standards = {} + required_standards = self._get_required_standards(preset.mode) + + for standard in required_standards: + file_path = calib_dir / f"{standard.value}.json" + standards[standard.value] = file_path.exists() + + return { + 'calibration_name': calibration_name, + 'standards': standards, + 'is_complete': all(standards.values()) + } + + def set_current_calibration(self, preset: ConfigPreset, calibration_name: str): + """Set current calibration by creating symlink""" + preset_dir = self._get_preset_calibration_dir(preset) + calib_dir = preset_dir / calibration_name + + if not calib_dir.exists(): + raise FileNotFoundError(f"Calibration not found: {calibration_name}") + + # Check if calibration is complete + info = self.get_calibration_info(preset, calibration_name) + if not info.get('is_complete', False): + raise ValueError(f"Calibration {calibration_name} is incomplete") + + # Remove existing symlink if present + if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink(): + self.current_calibration_symlink.unlink() + + # Create new symlink + try: + relative_path = calib_dir.relative_to(self.calibration_dir) + except ValueError: + relative_path = calib_dir + + self.current_calibration_symlink.symlink_to(relative_path) + + def get_current_calibration(self) -> CalibrationSet | None: + """Get currently selected calibration as CalibrationSet""" + if not self.current_calibration_symlink.exists(): + return None + + try: + target = self.current_calibration_symlink.resolve() + calibration_name = target.name + preset_name = target.parent.name + + # Parse preset from filename + preset = self.preset_manager._parse_filename(f"{preset_name}.bin") + + if preset is None: + return None + + # Load and return the calibration set + return self.load_calibration_set(preset, calibration_name) + except Exception: + return None + + def clear_current_calibration(self): + """Clear current calibration symlink""" + if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink(): + self.current_calibration_symlink.unlink() + + def delete_calibration(self, preset: ConfigPreset, calibration_name: str): + """Delete calibration set""" + preset_dir = self._get_preset_calibration_dir(preset) + calib_dir = preset_dir / calibration_name + + if calib_dir.exists(): + shutil.rmtree(calib_dir) + + def _get_preset_calibration_dir(self, preset: ConfigPreset) -> Path: + """Get calibration directory for specific preset""" + preset_dir = self.calibration_dir / preset.filename.replace('.bin', '') + preset_dir.mkdir(parents=True, exist_ok=True) + return preset_dir + + def _get_required_standards(self, mode: VNAMode) -> List[CalibrationStandard]: + """Get required calibration standards for VNA mode""" + if mode == VNAMode.S11: + return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] + elif mode == VNAMode.S21: + return [CalibrationStandard.THROUGH] + return [] \ No newline at end of file diff --git a/vna_system/core/settings/preset_manager.py b/vna_system/core/settings/preset_manager.py new file mode 100644 index 0000000..54f5f64 --- /dev/null +++ b/vna_system/core/settings/preset_manager.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import List + +from vna_system.config import config as cfg + + +class VNAMode(Enum): + S11 = "s11" + S21 = "s21" + + +@dataclass +class ConfigPreset: + filename: str + mode: VNAMode + start_freq: float | None = None + stop_freq: float | None = None + points: int | None = None + bandwidth: float | None = None + + +class PresetManager: + def __init__(self, binary_input_dir: Path | None = None): + self.binary_input_dir = Path(binary_input_dir or cfg.BASE_DIR / "vna_system" / "binary_input") + self.config_inputs_dir = self.binary_input_dir / "config_inputs" + self.current_input_symlink = self.binary_input_dir / "current_input.bin" + + self.config_inputs_dir.mkdir(parents=True, exist_ok=True) + + def _parse_filename(self, filename: str) -> ConfigPreset | None: + """Parse configuration parameters from filename like s11_start100_stop8800_points1000_bw1khz.bin""" + base_name = Path(filename).stem.lower() + + # Extract mode - must be at the beginning + mode = None + if base_name.startswith('s11'): + mode = VNAMode.S11 + elif base_name.startswith('s21'): + mode = VNAMode.S21 + else: + return None + + preset = ConfigPreset(filename=filename, mode=mode) + + # Extract parameters using regex + patterns = { + 'start': r'start(\d+(?:\.\d+)?)', + 'stop': r'stop(\d+(?:\.\d+)?)', + 'points': r'points?(\d+)', + 'bw': r'bw(\d+(?:\.\d+)?)(hz|khz|mhz)?' + } + + for param, pattern in patterns.items(): + match = re.search(pattern, base_name) + if match: + value = float(match.group(1)) + + if param == 'start': + # Assume MHz if no unit specified + preset.start_freq = value * 1e6 + elif param == 'stop': + # Assume MHz if no unit specified + preset.stop_freq = value * 1e6 + elif param == 'points': + preset.points = int(value) + elif param == 'bw': + unit = match.group(2) if len(match.groups()) > 1 and match.group(2) else 'hz' + if unit == 'khz': + value *= 1e3 + elif unit == 'mhz': + value *= 1e6 + # hz is base unit, no multiplication needed + preset.bandwidth = value + + return preset + + def get_available_presets(self) -> List[ConfigPreset]: + """Return list of all available configuration presets""" + presets = [] + + if not self.config_inputs_dir.exists(): + return presets + for file_path in self.config_inputs_dir.glob("*.bin"): + preset = self._parse_filename(file_path.name) + if preset is not None: + presets.append(preset) + + return sorted(presets, key=lambda x: x.filename) + + def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset: + """Set current configuration by creating symlink to specified preset""" + preset_path = self.config_inputs_dir / preset.filename + + if not preset_path.exists(): + raise FileNotFoundError(f"Preset file not found: {preset.filename}") + + # Remove existing symlink if present + if self.current_input_symlink.exists() or self.current_input_symlink.is_symlink(): + self.current_input_symlink.unlink() + + # Create new symlink + try: + relative_path = preset_path.relative_to(self.binary_input_dir) + except ValueError: + relative_path = preset_path + + self.current_input_symlink.symlink_to(relative_path) + + return preset + + def get_current_preset(self) -> ConfigPreset | None: + """Get currently selected configuration preset""" + if not self.current_input_symlink.exists(): + return None + + try: + target = self.current_input_symlink.resolve() + return self._parse_filename(target.name) + except Exception: + return None + + def preset_exists(self, preset: ConfigPreset) -> bool: + """Check if preset file exists""" + return (self.config_inputs_dir / preset.filename).exists() \ No newline at end of file diff --git a/vna_system/core/settings/settings_manager.py b/vna_system/core/settings/settings_manager.py index 261c290..8225882 100644 --- a/vna_system/core/settings/settings_manager.py +++ b/vna_system/core/settings/settings_manager.py @@ -1,352 +1,176 @@ from __future__ import annotations -import json import logging -import re -from dataclasses import dataclass -from datetime import datetime -from enum import Enum from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List from vna_system.config import config as cfg -from vna_system.core.processing.results_storage import ResultsStorage +from vna_system.core.acquisition.sweep_buffer import SweepData +from .preset_manager import PresetManager, ConfigPreset, VNAMode +from .calibration_manager import CalibrationManager, CalibrationSet, CalibrationStandard logger = logging.getLogger(__name__) -# ----------------------- Minimal enums & dataclass ----------------------- - -class VNAMode(Enum): - S11 = "S11" - S21 = "S21" - - -class CalibrationStandard(Enum): - OPEN = "open" - SHORT = "short" - LOAD = "load" - THROUGH = "through" - - -@dataclass(frozen=True) -class LogConfig: - """Parsed configuration from a selected config file.""" - mode: VNAMode - file_path: Path - stem: str - start_hz: float | None - stop_hz: float | None - points: int | None - bw_hz: float | None - - -# ----------------------- Filename parsing helpers -------------------------- - -_UNIT_MULT = { - "hz": 1.0, - "khz": 1e3, - "mhz": 1e6, - "ghz": 1e9, -} -_PARAM_RE = re.compile(r"^(str|stp|pnts|bw)(?P[0-9]+(?:\.[0-9]+)?)(?P[a-zA-Z]+)?$") - - -def _to_hz(val: float, unit: str | None, default_hz: float) -> float: - if unit: - m = _UNIT_MULT.get(unit.lower()) - if m: - return float(val) * m - return float(val) * default_hz - - -def parse_config_filename(name: str, assume_mhz_for_freq: bool = True) -> Tuple[float | None, float | None, int | None, float] | None: - """ - Parse tokens like: str100_stp8800_pnts1000_bw1khz.[bin] - - str/stp default to MHz if no unit (configurable) - - bw defaults to Hz if no unit - """ - base = Path(name).stem - tokens = base.split("_") - - start_hz = stop_hz = bw_hz = None - points: int | None = None - - for t in tokens: - m = _PARAM_RE.match(t) - if not m: - continue - key = t[:3] - val = float(m.group("val")) - unit = m.group("unit") - - if key == "str": - start_hz = _to_hz(val, unit, 1e6 if assume_mhz_for_freq else 1.0) - elif key == "stp": - stop_hz = _to_hz(val, unit, 1e6 if assume_mhz_for_freq else 1.0) - elif key == "pnt": # token 'pnts' - points = int(val) - elif key == "bw": - bw_hz = _to_hz(val, unit, 1.0) - - return start_hz, stop_hz, points, bw_hz - - -# ----------------------- VNA Settings Manager ------------------------------ - class VNASettingsManager: """ - - Scans config_logs/{S11,S21}/ for available configs - - Controls current_log.bin symlink (must be a real symlink) - - Parses config params from filename - - Stores per-config calibration in: - calibration////_sweepNNNNNN/ - - copies ALL processor result JSON files for that sweep (and metadata.json if present) - - UI helpers: select S11/S21, calibrate (through/open/short/load) by sweep number + Main settings manager that coordinates preset and calibration management. + + Provides high-level interface for: + - Managing configuration presets + - Managing calibration data + - Coordinating between preset selection and calibration """ - def __init__( - self, - base_dir: Path | None = None, - config_logs_subdir: str = "binary_logs/config_logs", - current_log_name: str = "current_log.bin", - calibration_subdir: str = "calibration", - assume_mhz_for_freq: bool = True, - results_storage: ResultsStorage | None = None, - ): + def __init__(self, base_dir: Path | None = None): self.base_dir = Path(base_dir or cfg.BASE_DIR) - self.cfg_logs_dir = self.base_dir / config_logs_subdir - self.current_log = self.cfg_logs_dir / current_log_name - self.calib_root = self.base_dir / calibration_subdir - self.assume_mhz_for_freq = assume_mhz_for_freq - # Ensure directory structure exists - (self.cfg_logs_dir / "S11").mkdir(parents=True, exist_ok=True) - (self.cfg_logs_dir / "S21").mkdir(parents=True, exist_ok=True) - self.calib_root.mkdir(parents=True, exist_ok=True) + # Initialize sub-managers + self.preset_manager = PresetManager(self.base_dir / "binary_input") + self.calibration_manager = CalibrationManager(self.preset_manager, self.base_dir) - # Results storage - self.results = results_storage or ResultsStorage( - storage_dir=str(self.base_dir / "processing_results") - ) + # ---------- Preset Management ---------- - # ---------- configuration selection & discovery ---------- + def get_available_presets(self) -> List[ConfigPreset]: + """Get all available configuration presets""" + return self.preset_manager.get_available_presets() - def list_configs(self, mode: VNAMode | None = None) -> List[LogConfig]: - modes = [mode] if mode else [VNAMode.S11, VNAMode.S21] - out: List[LogConfig] = [] - for m in modes: - d = self.cfg_logs_dir / m.value - if not d.exists(): - continue - for fp in sorted(d.glob("*.bin")): - s, e, n, bw = parse_config_filename(fp.name, self.assume_mhz_for_freq) - out.append(LogConfig( - mode=m, - file_path=fp.resolve(), - stem=fp.stem, - start_hz=s, - stop_hz=e, - points=n, - bw_hz=bw, - )) - return out + def get_presets_by_mode(self, mode: VNAMode) -> List[ConfigPreset]: + """Get presets filtered by VNA mode""" + all_presets = self.get_available_presets() + return [p for p in all_presets if p.mode == mode] - def set_current_config(self, mode: VNAMode, filename: str) -> LogConfig: - """ - Update current_log.bin symlink to point to config_logs//. - Real symlink only; will raise if not supported. - """ - target = (self.cfg_logs_dir / mode.value / filename).resolve() - if not target.exists(): - raise FileNotFoundError(f"Config not found: {target}") + def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset: + """Set current configuration preset""" + return self.preset_manager.set_current_preset(preset) - if self.current_log.exists() or self.current_log.is_symlink(): - self.current_log.unlink() + def get_current_preset(self) -> ConfigPreset | None: + """Get currently selected preset""" + return self.preset_manager.get_current_preset() - # relative link if possible, else absolute (still a symlink) - try: - rel = target.relative_to(self.current_log.parent) - except ValueError: - rel = target + # ---------- Calibration Management ---------- - self.current_log.symlink_to(rel) - return self.get_current_config() + def start_new_calibration(self, preset: ConfigPreset | None = None) -> CalibrationSet: + """Start new calibration for current or specified preset""" + if preset is None: + preset = self.get_current_preset() + if preset is None: + raise RuntimeError("No current preset selected") - def get_current_config(self) -> LogConfig: - if not self.current_log.exists(): - raise FileNotFoundError(f"{self.current_log} does not exist") + return self.calibration_manager.start_new_calibration(preset) - tgt = self.current_log.resolve() - mode = VNAMode(tgt.parent.name) # expects .../config_logs// - s, e, n, bw = parse_config_filename(tgt.name, self.assume_mhz_for_freq) - return LogConfig( - mode=mode, - file_path=tgt, - stem=tgt.stem, - start_hz=s, stop_hz=e, points=n, bw_hz=bw - ) - # ---------- calibration capture (ALL processors) ---------- + def get_current_working_calibration(self) -> CalibrationSet | None: + """Get current working calibration set (in-progress, not yet saved)""" + return self.calibration_manager.get_current_working_set() - @staticmethod - def required_standards(mode: VNAMode) -> List[CalibrationStandard]: - return ( - [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] - if mode == VNAMode.S11 - else [CalibrationStandard.THROUGH] - ) + def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData): + """Add calibration standard to current working set""" + self.calibration_manager.add_calibration_standard(standard, sweep_data) - def _calib_dir(self, cfg: LogConfig, standard: CalibrationStandard | None = None) -> Path: - base = self.calib_root / cfg.mode.value / cfg.stem - return base / standard.value if standard else base + def remove_calibration_standard(self, standard: CalibrationStandard): + """Remove calibration standard from current working set""" + self.calibration_manager.remove_calibration_standard(standard) - def _calib_sweep_dir(self, cfg: LogConfig, standard: CalibrationStandard, sweep_number: int, ts: str | None = None) -> Path: - """ - calibration////_sweepNNNNNN/ - """ - ts = ts or datetime.now().strftime("%Y%m%d_%H%M%S") - d = self._calib_dir(cfg, standard) / f"{ts}_sweep{sweep_number:06d}" - d.mkdir(parents=True, exist_ok=True) - return d + def save_calibration_set(self, calibration_name: str) -> CalibrationSet: + """Save current working calibration set""" + return self.calibration_manager.save_calibration_set(calibration_name) - def record_calibration_from_sweep( - self, - standard: CalibrationStandard, - sweep_number: int, - *, - cfg: LogConfig | None = None - ) -> Path: - """ - Capture ALL processor JSON results for the given sweep and save under: - calibration////_sweepNNNNNN/ - Also copy metadata.json if available. - Returns the created sweep calibration directory. - """ - cfg = cfg or self.get_current_config() + def get_available_calibrations(self, preset: ConfigPreset | None = None) -> List[str]: + """Get available calibrations for current or specified preset""" + if preset is None: + preset = self.get_current_preset() + if preset is None: + return [] - # Get ALL results for the sweep - results = self.results.get_result_by_sweep(sweep_number, processor_name=None) - if not results: - raise FileNotFoundError(f"No processor results found for sweep {sweep_number}") + return self.calibration_manager.get_available_calibrations(preset) - # Determine destination dir - dst_dir = self._calib_sweep_dir(cfg, standard, sweep_number) + def set_current_calibration(self, calibration_name: str, preset: ConfigPreset | None = None): + """Set current calibration""" + if preset is None: + preset = self.get_current_preset() + if preset is None: + raise RuntimeError("No current preset selected") - # Save processor files (re-serialize what ResultsStorage returns) - count = 0 - for r in results: - try: - dst_file = dst_dir / f"{r.processor_name}.json" - payload = { - "processor_name": r.processor_name, - "sweep_number": r.sweep_number, - "data": r.data, - } - # keep optional fields if present - if getattr(r, "plotly_figure", None) is not None: - payload["plotly_figure"] = r.plotly_figure - if getattr(r, "file_path", None) is not None: - payload["file_path"] = r.file_path + self.calibration_manager.set_current_calibration(preset, calibration_name) - with open(dst_file, "w") as f: - json.dump(payload, f, indent=2) - count += 1 - except Exception as e: - logger.error(f"Failed to store processor '{r.processor_name}' for sweep {sweep_number}: {e}") + def get_current_calibration(self) -> CalibrationSet | None: + """Get currently selected calibration set (saved and active via symlink)""" + return self.calibration_manager.get_current_calibration() - # Save metadata if available - try: - meta = self.results.get_sweep_metadata(sweep_number) - if meta: - with open(dst_dir / "metadata.json", "w") as f: - json.dump(meta, f, indent=2) - except Exception as e: - logger.warning(f"Failed to write metadata for sweep {sweep_number}: {e}") + def get_calibration_info(self, calibration_name: str, preset: ConfigPreset | None = None) -> Dict: + """Get calibration information""" + if preset is None: + preset = self.get_current_preset() + if preset is None: + raise RuntimeError("No current preset selected") - if count == 0: - raise RuntimeError(f"Nothing was written for sweep {sweep_number}") + return self.calibration_manager.get_calibration_info(preset, calibration_name) - logger.info(f"Stored calibration (standard={standard.value}) from sweep {sweep_number} into {dst_dir}") - return dst_dir + # ---------- Combined Status and UI helpers ---------- - def latest_calibration(self, cfg: LogConfig | None = None) -> Dict[CalibrationStandard, Path] | None: - """ - Returns the latest sweep directory per required standard for the current (or provided) config. - """ - cfg = cfg or self.get_current_config() - out: Dict[CalibrationStandard, Path] | None = {} - for std in self.required_standards(cfg.mode): - d = self._calib_dir(cfg, std) - if not d.exists(): - out[std] = None - continue - subdirs = sorted([p for p in d.iterdir() if p.is_dir()]) - out[std] = subdirs[-1] if subdirs else None - return out + def get_status_summary(self) -> Dict[str, object]: + """Get comprehensive status of current configuration and calibration""" + current_preset = self.get_current_preset() + current_calibration = self.get_current_calibration() + working_calibration = self.get_current_working_calibration() - def calibration_status(self, cfg: LogConfig | None = None) -> Dict[str, bool]: - cfg = cfg or self.get_current_config() - latest = self.latest_calibration(cfg) - return {std.value: (p is not None and p.exists()) for std, p in latest.items()} - - def is_fully_calibrated(self, cfg: LogConfig | None = None) -> bool: - return all(self.calibration_status(cfg).values()) - - # ---------- UI helpers ---------- - - def summary(self) -> Dict[str, object]: - cfg = self.get_current_config() - latest = self.latest_calibration(cfg) - return { - "mode": cfg.mode.value, - "current_log": str(self.current_log), - "selected_file": str(cfg.file_path), - "stem": cfg.stem, - "params": { - "start_hz": cfg.start_hz, - "stop_hz": cfg.stop_hz, - "points": cfg.points, - "bw_hz": cfg.bw_hz, - }, - "required_standards": [s.value for s in self.required_standards(cfg.mode)], - "calibration_latest": {k.value: (str(v) if v else None) for k, v in latest.items()}, - "is_fully_calibrated": self.is_fully_calibrated(cfg), + summary = { + "current_preset": None, + "current_calibration": None, + "working_calibration": None, + "available_presets": len(self.get_available_presets()), + "available_calibrations": 0 } - def ui_select_S11(self, filename: str) -> Dict[str, object]: - self.set_current_config(VNAMode.S11, filename) - return self.summary() + if current_preset: + summary["current_preset"] = { + "filename": current_preset.filename, + "mode": current_preset.mode.value, + "start_freq": current_preset.start_freq, + "stop_freq": current_preset.stop_freq, + "points": current_preset.points, + "bandwidth": current_preset.bandwidth + } + summary["available_calibrations"] = len(self.get_available_calibrations(current_preset)) - def ui_select_S21(self, filename: str) -> Dict[str, object]: - self.set_current_config(VNAMode.S21, filename) - return self.summary() + if current_calibration: + summary["current_calibration"] = { + "preset_filename": current_calibration.preset.filename, + "calibration_name": current_calibration.name + } - # Calibration triggers (buttons) - def ui_calibrate_through(self, sweep_number: int) -> Dict[str, object]: - cfg = self.get_current_config() - if cfg.mode != VNAMode.S21: - raise RuntimeError("THROUGH is only valid in S21 mode") - self.record_calibration_from_sweep(CalibrationStandard.THROUGH, sweep_number) - return self.summary() + if working_calibration: + completed, total = working_calibration.get_progress() + summary["working_calibration"] = { + "preset_filename": working_calibration.preset.filename, + "progress": f"{completed}/{total}", + "is_complete": working_calibration.is_complete(), + "missing_standards": [s.value for s in working_calibration.get_missing_standards()] + } - def ui_calibrate_open(self, sweep_number: int) -> Dict[str, object]: - cfg = self.get_current_config() - if cfg.mode != VNAMode.S11: - raise RuntimeError("OPEN is only valid in S11 mode") - self.record_calibration_from_sweep(CalibrationStandard.OPEN, sweep_number) - return self.summary() + return summary - def ui_calibrate_short(self, sweep_number: int) -> Dict[str, object]: - cfg = self.get_current_config() - if cfg.mode != VNAMode.S11: - raise RuntimeError("SHORT is only valid in S11 mode") - self.record_calibration_from_sweep(CalibrationStandard.SHORT, sweep_number) - return self.summary() + @staticmethod + def get_required_standards(mode: VNAMode) -> List[CalibrationStandard]: + """Get required calibration standards for VNA mode""" + if mode == VNAMode.S11: + return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] + elif mode == VNAMode.S21: + return [CalibrationStandard.THROUGH] + return [] - def ui_calibrate_load(self, sweep_number: int) -> Dict[str, object]: - cfg = self.get_current_config() - if cfg.mode != VNAMode.S11: - raise RuntimeError("LOAD is only valid in S11 mode") - self.record_calibration_from_sweep(CalibrationStandard.LOAD, sweep_number) - return self.summary() + # ---------- Integration with VNADataAcquisition ---------- + + def capture_calibration_standard_from_acquisition(self, standard: CalibrationStandard, data_acquisition): + """Capture calibration standard from VNADataAcquisition instance""" + # Get latest sweep from acquisition + latest_sweep = data_acquisition._sweep_buffer.get_latest_sweep() + if latest_sweep is None: + raise RuntimeError("No sweep data available in acquisition buffer") + + # Add to current working calibration + self.add_calibration_standard(standard, latest_sweep) + + logger.info(f"Captured {standard.value} calibration standard from sweep {latest_sweep.sweep_number}") + return latest_sweep.sweep_number \ No newline at end of file diff --git a/vna_system/web_ui/static/css/settings.css b/vna_system/web_ui/static/css/settings.css new file mode 100644 index 0000000..52ccf8a --- /dev/null +++ b/vna_system/web_ui/static/css/settings.css @@ -0,0 +1,492 @@ +/* =================================================================== + Settings Page Styles - Professional Design + =================================================================== */ + +/* Settings Container */ +.settings-container { + max-width: var(--max-content-width); + margin: 0 auto; + padding: var(--space-8); + background: var(--bg-primary); + min-height: calc(100vh - var(--header-height)); +} + +.settings-section { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-8); +} + +.settings-title { + display: flex; + align-items: center; + gap: var(--space-4); + color: var(--text-primary); + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + margin: 0 0 var(--space-8) 0; + text-shadow: none; +} + +.settings-icon { + width: 2.5rem; + height: 2.5rem; + color: var(--color-primary-500); +} + +/* Professional Cards */ +.settings-card { + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + padding: var(--space-8); + box-shadow: var(--shadow-lg); + position: relative; + transition: all var(--transition-normal); +} + +.settings-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-600)); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; +} + +.settings-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-xl); + border-color: var(--border-secondary); +} + +.settings-card-title { + color: var(--text-primary); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + margin: 0 0 var(--space-3) 0; + display: flex; + align-items: center; + gap: var(--space-3); +} + +.settings-card-title::before { + content: ''; + width: 6px; + height: 24px; + background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600)); + border-radius: 3px; +} + +.settings-card-description { + color: var(--text-secondary); + font-size: var(--font-size-base); + margin: 0 0 var(--space-8) 0; + line-height: var(--line-height-relaxed); +} + +/* Enhanced Preset Controls */ +.preset-controls { + display: flex; + flex-direction: column; + gap: var(--space-8); +} + +.control-group { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-6); + transition: all var(--transition-fast); +} + +.control-group:hover { + border-color: var(--border-secondary); + background: var(--bg-surface-hover); +} + +.control-label { + color: var(--text-primary); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-base); + margin-bottom: var(--space-4); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.control-label::before { + content: ''; + width: 4px; + height: 4px; + background: var(--color-primary-500); + border-radius: 50%; +} + +.preset-selector { + display: flex; + gap: var(--space-4); + align-items: stretch; +} + +.preset-dropdown, +.calibration-dropdown { + flex: 1; + padding: var(--space-3) var(--space-4); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: var(--font-size-sm); + min-height: 3rem; + font-weight: var(--font-weight-medium); + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm); +} + +.preset-dropdown:focus, +.calibration-dropdown:focus { + outline: none; + border-color: var(--color-primary-500); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + background: var(--bg-surface); +} + +.preset-dropdown:hover, +.calibration-dropdown:hover { + border-color: var(--color-primary-500); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.preset-dropdown:disabled, +.calibration-dropdown:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.preset-info { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.preset-details { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); +} + +.preset-param { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.param-label { + color: var(--text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.param-value { + color: var(--text-primary); + font-size: var(--font-size-sm); + font-family: var(--font-mono); +} + +/* Calibration Status */ +.calibration-status { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-6); +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-2) 0; +} + +.status-label { + color: var(--text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} + +.status-value { + color: var(--text-primary); + font-size: var(--font-size-sm); + font-family: var(--font-mono); +} + +/* Calibration Workflow */ +.calibration-workflow { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.workflow-section { + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-6); + background: var(--bg-surface); + transition: all var(--transition-fast); +} + +.workflow-section:hover { + border-color: var(--border-secondary); +} + +.workflow-title { + color: var(--text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-4) 0; +} + +.workflow-controls { + display: flex; + gap: var(--space-3); + align-items: center; +} + +/* Calibration Progress */ +.calibration-progress { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-4); +} + +.progress-info { + text-align: center; +} + +.progress-text { + color: var(--text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + font-family: var(--font-mono); +} + +/* Calibration Standards */ +.calibration-standards { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.calibration-standard-btn { + min-width: 120px; + white-space: nowrap; +} + +.calibration-standard-btn i { + margin-right: var(--space-2); +} + +.calibration-standard-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Calibration Actions */ +.calibration-actions { + display: flex; + gap: var(--space-3); + align-items: stretch; + margin-top: var(--space-4); +} + +.calibration-name-input { + flex: 1; + padding: var(--space-3) var(--space-4); + background: var(--bg-primary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: var(--font-size-sm); + min-height: 2.5rem; + transition: all var(--transition-fast); +} + +.calibration-name-input:focus { + outline: none; + border-color: var(--color-primary-500); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.calibration-name-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.calibration-name-input::placeholder { + color: var(--text-tertiary); +} + +/* Existing Calibrations */ +.existing-calibrations { + display: flex; + gap: var(--space-3); + align-items: stretch; +} + +/* Status Grid */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); +} + +.status-grid .status-item { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); + transition: all var(--transition-fast); +} + +.status-grid .status-item:hover { + background: var(--bg-surface-hover); + border-color: var(--border-secondary); +} + +.status-grid .status-label { + font-size: var(--font-size-xs); + margin: 0; +} + +.status-grid .status-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + margin: 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .settings-container { + padding: var(--spacing-md); + } + + .preset-selector { + flex-direction: column; + } + + .calibration-actions { + flex-direction: column; + } + + .existing-calibrations { + flex-direction: column; + } + + .status-grid { + grid-template-columns: 1fr; + } + + .calibration-standards { + flex-direction: column; + } + + .calibration-standard-btn { + min-width: auto; + } +} + +/* Animation and Transitions */ +.settings-card { + transition: box-shadow 0.2s ease; +} + +.settings-card:hover { + box-shadow: var(--shadow-md); +} + +.workflow-section { + transition: border-color 0.2s ease; +} + +.workflow-section:hover { + border-color: var(--color-primary-alpha); +} + +/* Loading States */ +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +.btn.loading { + position: relative; + color: transparent; +} + +.btn.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Success/Error States */ +.preset-param .param-value.success { + color: var(--color-success-500); +} + +.preset-param .param-value.error { + color: var(--color-error-500); +} + +.preset-param .param-value.warning { + color: var(--color-warning-500); +} + +/* Calibration standard button states */ +.calibration-standard-btn.btn--success { + background-color: var(--color-success-500); + border-color: var(--color-success-500); + color: white; +} + +.calibration-standard-btn.btn--success:hover:not(:disabled) { + background-color: var(--color-success-600); + border-color: var(--color-success-600); +} + +.calibration-standard-btn.btn--warning { + background-color: var(--color-warning-500); + border-color: var(--color-warning-500); + color: white; +} + +.calibration-standard-btn.btn--warning:hover:not(:disabled) { + background-color: var(--color-warning-600); + border-color: var(--color-warning-600); +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/main.js b/vna_system/web_ui/static/js/main.js index 1e8996f..4902daf 100644 --- a/vna_system/web_ui/static/js/main.js +++ b/vna_system/web_ui/static/js/main.js @@ -8,6 +8,7 @@ import { ChartManager } from './modules/charts.js'; import { UIManager } from './modules/ui.js'; import { NotificationManager } from './modules/notifications.js'; import { StorageManager } from './modules/storage.js'; +import { SettingsManager } from './modules/settings.js'; /** * Main Application Class @@ -40,6 +41,7 @@ class VNADashboard { this.ui = new UIManager(this.notifications); this.charts = new ChartManager(this.config.charts, this.notifications); this.websocket = new WebSocketManager(this.config.websocket, this.notifications); + this.settings = new SettingsManager(this.notifications); // Bind methods this.handleWebSocketData = this.handleWebSocketData.bind(this); @@ -98,6 +100,9 @@ class VNADashboard { // Initialize chart manager await this.charts.init(); + // Initialize settings manager + await this.settings.init(); + // Set up UI event handlers this.setupUIHandlers(); @@ -144,6 +149,11 @@ class VNADashboard { // Navigation this.ui.onViewChange((view) => { console.log(`๐Ÿ“ฑ Switched to view: ${view}`); + + // Refresh settings when switching to settings view + if (view === 'settings') { + this.settings.refresh(); + } }); // Processor toggles @@ -347,6 +357,7 @@ class VNADashboard { // Cleanup managers this.charts.destroy(); + this.settings.destroy(); this.ui.destroy(); this.notifications.destroy(); @@ -389,6 +400,7 @@ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') dashboard: () => window.vnaDashboard, websocket: () => window.vnaDashboard?.websocket, charts: () => window.vnaDashboard?.charts, - ui: () => window.vnaDashboard?.ui + ui: () => window.vnaDashboard?.ui, + settings: () => window.vnaDashboard?.settings }; } \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/settings.js b/vna_system/web_ui/static/js/modules/settings.js new file mode 100644 index 0000000..d6563b5 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/settings.js @@ -0,0 +1,585 @@ +/** + * Settings Manager Module + * Handles VNA configuration presets and calibration management + */ + +export class SettingsManager { + constructor(notifications) { + this.notifications = notifications; + this.isInitialized = false; + this.currentPreset = null; + this.currentCalibration = null; + this.workingCalibration = null; + + // DOM elements will be populated during init + this.elements = {}; + + // Bind methods + this.handlePresetChange = this.handlePresetChange.bind(this); + this.handleSetPreset = this.handleSetPreset.bind(this); + this.handleStartCalibration = this.handleStartCalibration.bind(this); + this.handleCalibrateStandard = this.handleCalibrateStandard.bind(this); + this.handleSaveCalibration = this.handleSaveCalibration.bind(this); + this.handleSetCalibration = this.handleSetCalibration.bind(this); + this.handleCalibrationChange = this.handleCalibrationChange.bind(this); + } + + async init() { + try { + console.log('๐Ÿ”ง Initializing Settings Manager...'); + + // Get DOM elements + this.initializeElements(); + + // Set up event listeners + this.setupEventListeners(); + + // Load initial data + await this.loadInitialData(); + + this.isInitialized = true; + console.log('โœ… Settings Manager initialized'); + } catch (error) { + console.error('โŒ Settings Manager initialization failed:', error); + this.notifications.show({ + type: 'error', + title: 'Settings Error', + message: 'Failed to initialize settings' + }); + } + } + + initializeElements() { + this.elements = { + // Preset elements + presetDropdown: document.getElementById('presetDropdown'), + setPresetBtn: document.getElementById('setPresetBtn'), + currentPreset: document.getElementById('currentPreset'), + + // Calibration elements + currentCalibration: document.getElementById('currentCalibration'), + startCalibrationBtn: document.getElementById('startCalibrationBtn'), + calibrationSteps: document.getElementById('calibrationSteps'), + calibrationStandards: document.getElementById('calibrationStandards'), + progressText: document.getElementById('progressText'), + calibrationNameInput: document.getElementById('calibrationNameInput'), + saveCalibrationBtn: document.getElementById('saveCalibrationBtn'), + calibrationDropdown: document.getElementById('calibrationDropdown'), + setCalibrationBtn: document.getElementById('setCalibrationBtn'), + + // Status elements + presetCount: document.getElementById('presetCount'), + calibrationCount: document.getElementById('calibrationCount'), + systemStatus: document.getElementById('systemStatus') + }; + } + + setupEventListeners() { + // Preset controls + this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange); + this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset); + + // Calibration controls + this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration); + this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration); + this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange); + this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration); + + // Calibration name input + this.elements.calibrationNameInput?.addEventListener('input', () => { + const hasName = this.elements.calibrationNameInput.value.trim().length > 0; + const isComplete = this.workingCalibration && this.workingCalibration.is_complete; + this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete; + }); + } + + async loadInitialData() { + await Promise.all([ + this.loadPresets(), + this.loadStatus(), + this.loadWorkingCalibration() + ]); + } + + async loadPresets() { + try { + const response = await fetch('/api/v1/settings/presets'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const presets = await response.json(); + this.populatePresetDropdown(presets); + + } catch (error) { + console.error('Failed to load presets:', error); + this.notifications.show({ + type: 'error', + title: 'Load Error', + message: 'Failed to load configuration presets' + }); + } + } + + async loadStatus() { + try { + const response = await fetch('/api/v1/settings/status'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const status = await response.json(); + this.updateStatusDisplay(status); + + } catch (error) { + console.error('Failed to load status:', error); + } + } + + async loadWorkingCalibration() { + try { + const response = await fetch('/api/v1/settings/working-calibration'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const workingCalibration = await response.json(); + this.updateWorkingCalibration(workingCalibration); + + } catch (error) { + console.error('Failed to load working calibration:', error); + } + } + + async loadCalibrations() { + if (!this.currentPreset) return; + + try { + const response = await fetch(`/api/v1/settings/calibrations?preset_filename=${this.currentPreset.filename}`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const calibrations = await response.json(); + this.populateCalibrationDropdown(calibrations); + + } catch (error) { + console.error('Failed to load calibrations:', error); + } + } + + populatePresetDropdown(presets) { + const dropdown = this.elements.presetDropdown; + dropdown.innerHTML = ''; + + if (presets.length === 0) { + dropdown.innerHTML = ''; + dropdown.disabled = true; + this.elements.setPresetBtn.disabled = true; + return; + } + + dropdown.innerHTML = ''; + presets.forEach(preset => { + const option = document.createElement('option'); + option.value = preset.filename; + option.textContent = this.formatPresetDisplay(preset); + dropdown.appendChild(option); + }); + + dropdown.disabled = false; + this.elements.setPresetBtn.disabled = true; + } + + populateCalibrationDropdown(calibrations) { + const dropdown = this.elements.calibrationDropdown; + dropdown.innerHTML = ''; + + if (calibrations.length === 0) { + dropdown.innerHTML = ''; + dropdown.disabled = true; + this.elements.setCalibrationBtn.disabled = true; + return; + } + + dropdown.innerHTML = ''; + calibrations.forEach(calibration => { + const option = document.createElement('option'); + option.value = calibration.name; + option.textContent = `${calibration.name} ${calibration.is_complete ? 'โœ“' : 'โš '}`; + dropdown.appendChild(option); + }); + + dropdown.disabled = false; + this.elements.setCalibrationBtn.disabled = true; + } + + formatPresetDisplay(preset) { + let display = `${preset.filename} (${preset.mode})`; + + if (preset.start_freq && preset.stop_freq) { + const startMHz = (preset.start_freq / 1e6).toFixed(0); + const stopMHz = (preset.stop_freq / 1e6).toFixed(0); + display += ` - ${startMHz}-${stopMHz}MHz`; + } + + if (preset.points) { + display += `, ${preset.points}pts`; + } + + return display; + } + + updateStatusDisplay(status) { + // Update current preset + if (status.current_preset) { + this.currentPreset = status.current_preset; + this.elements.currentPreset.textContent = status.current_preset.filename; + this.elements.startCalibrationBtn.disabled = false; + + // Load calibrations for current preset + this.loadCalibrations(); + } else { + this.currentPreset = null; + this.elements.currentPreset.textContent = 'None'; + this.elements.startCalibrationBtn.disabled = true; + } + + // Update current calibration + if (status.current_calibration) { + this.currentCalibration = status.current_calibration; + this.elements.currentCalibration.textContent = status.current_calibration.calibration_name; + } else { + this.currentCalibration = null; + this.elements.currentCalibration.textContent = 'None'; + } + + // Update counts + this.elements.presetCount.textContent = status.available_presets || 0; + this.elements.calibrationCount.textContent = status.available_calibrations || 0; + this.elements.systemStatus.textContent = 'Ready'; + } + + updateWorkingCalibration(workingCalibration) { + this.workingCalibration = workingCalibration; + + if (workingCalibration.active) { + this.showCalibrationSteps(workingCalibration); + } else { + this.hideCalibrationSteps(); + } + } + + showCalibrationSteps(workingCalibration) { + this.elements.calibrationSteps.style.display = 'block'; + this.elements.progressText.textContent = workingCalibration.progress || '0/0'; + + // Generate standard buttons + this.generateStandardButtons(workingCalibration); + + // Update save button state + const hasName = this.elements.calibrationNameInput.value.trim().length > 0; + this.elements.saveCalibrationBtn.disabled = !hasName || !workingCalibration.is_complete; + this.elements.calibrationNameInput.disabled = false; + } + + hideCalibrationSteps() { + this.elements.calibrationSteps.style.display = 'none'; + this.elements.calibrationStandards.innerHTML = ''; + } + + generateStandardButtons(workingCalibration) { + const container = this.elements.calibrationStandards; + container.innerHTML = ''; + + const allStandards = this.getAllStandardsForMode(); + const completedStandards = workingCalibration.completed_standards || []; + const missingStandards = workingCalibration.missing_standards || []; + + allStandards.forEach(standard => { + const button = document.createElement('button'); + button.className = 'btn calibration-standard-btn'; + button.dataset.standard = standard; + + const isCompleted = completedStandards.includes(standard); + const isMissing = missingStandards.includes(standard); + + if (isCompleted) { + button.classList.add('btn--success'); + button.innerHTML = ` ${standard.toUpperCase()} โœ“`; + button.disabled = false; + button.title = 'Click to recapture this standard'; + } else if (isMissing) { + button.classList.add('btn--primary'); + button.innerHTML = ` Capture ${standard.toUpperCase()}`; + button.disabled = false; + button.title = 'Click to capture this standard'; + } else { + button.classList.add('btn--secondary'); + button.innerHTML = `${standard.toUpperCase()}`; + button.disabled = true; + } + + button.addEventListener('click', () => this.handleCalibrateStandard(standard)); + container.appendChild(button); + }); + + // Re-initialize lucide icons for new buttons + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + } + + getAllStandardsForMode() { + if (!this.currentPreset) return []; + + if (this.currentPreset.mode === 's11') { + return ['open', 'short', 'load']; + } else if (this.currentPreset.mode === 's21') { + return ['through']; + } + + return []; + } + + // Event handlers + handlePresetChange() { + const selectedValue = this.elements.presetDropdown.value; + this.elements.setPresetBtn.disabled = !selectedValue; + } + + async handleSetPreset() { + const filename = this.elements.presetDropdown.value; + if (!filename) return; + + try { + this.elements.setPresetBtn.disabled = true; + this.elements.setPresetBtn.textContent = 'Setting...'; + + const response = await fetch('/api/v1/settings/preset/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + this.notifications.show({ + type: 'success', + title: 'Preset Set', + message: result.message + }); + + // Reload status + await this.loadStatus(); + + } catch (error) { + console.error('Failed to set preset:', error); + this.notifications.show({ + type: 'error', + title: 'Preset Error', + message: 'Failed to set configuration preset' + }); + } finally { + this.elements.setPresetBtn.disabled = false; + this.elements.setPresetBtn.innerHTML = ' Set Active'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + } + } + + async handleStartCalibration() { + if (!this.currentPreset) return; + + try { + this.elements.startCalibrationBtn.disabled = true; + this.elements.startCalibrationBtn.textContent = 'Starting...'; + + const response = await fetch('/api/v1/settings/calibration/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preset_filename: this.currentPreset.filename }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + this.notifications.show({ + type: 'info', + title: 'Calibration Started', + message: `Started calibration for ${result.preset}` + }); + + // Reload working calibration + await this.loadWorkingCalibration(); + + } catch (error) { + console.error('Failed to start calibration:', error); + this.notifications.show({ + type: 'error', + title: 'Calibration Error', + message: 'Failed to start calibration' + }); + } finally { + this.elements.startCalibrationBtn.disabled = false; + this.elements.startCalibrationBtn.innerHTML = ' Start Calibration'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + } + } + + async handleCalibrateStandard(standard) { + try { + const button = document.querySelector(`[data-standard="${standard}"]`); + if (button) { + button.disabled = true; + button.textContent = 'Capturing...'; + } + + const response = await fetch('/api/v1/settings/calibration/add-standard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ standard }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + this.notifications.show({ + type: 'success', + title: 'Standard Captured', + message: result.message + }); + + // Reload working calibration + await this.loadWorkingCalibration(); + + } catch (error) { + console.error('Failed to capture standard:', error); + this.notifications.show({ + type: 'error', + title: 'Calibration Error', + message: 'Failed to capture calibration standard' + }); + + // Re-enable button + const button = document.querySelector(`[data-standard="${standard}"]`); + if (button) { + button.disabled = false; + this.generateStandardButtons(this.workingCalibration); + } + } + } + + async handleSaveCalibration() { + const name = this.elements.calibrationNameInput.value.trim(); + if (!name) return; + + try { + this.elements.saveCalibrationBtn.disabled = true; + this.elements.saveCalibrationBtn.textContent = 'Saving...'; + + const response = await fetch('/api/v1/settings/calibration/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + this.notifications.show({ + type: 'success', + title: 'Calibration Saved', + message: result.message + }); + + // Clear working calibration + this.hideCalibrationSteps(); + this.elements.calibrationNameInput.value = ''; + + // Reload data + await Promise.all([ + this.loadStatus(), + this.loadWorkingCalibration(), + this.loadCalibrations() + ]); + + } catch (error) { + console.error('Failed to save calibration:', error); + this.notifications.show({ + type: 'error', + title: 'Calibration Error', + message: 'Failed to save calibration' + }); + } finally { + this.elements.saveCalibrationBtn.disabled = true; + this.elements.saveCalibrationBtn.innerHTML = ' Save Calibration'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + } + } + + handleCalibrationChange() { + const selectedValue = this.elements.calibrationDropdown.value; + this.elements.setCalibrationBtn.disabled = !selectedValue; + } + + async handleSetCalibration() { + const name = this.elements.calibrationDropdown.value; + if (!name || !this.currentPreset) return; + + try { + this.elements.setCalibrationBtn.disabled = true; + this.elements.setCalibrationBtn.textContent = 'Setting...'; + + const response = await fetch('/api/v1/settings/calibration/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + preset_filename: this.currentPreset.filename + }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const result = await response.json(); + + this.notifications.show({ + type: 'success', + title: 'Calibration Set', + message: result.message + }); + + // Reload status + await this.loadStatus(); + + } catch (error) { + console.error('Failed to set calibration:', error); + this.notifications.show({ + type: 'error', + title: 'Calibration Error', + message: 'Failed to set active calibration' + }); + } finally { + this.elements.setCalibrationBtn.disabled = false; + this.elements.setCalibrationBtn.innerHTML = ' Set Active'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + } + } + + // Public methods for external use + async refresh() { + if (!this.isInitialized) return; + + await this.loadInitialData(); + } + + destroy() { + // Remove event listeners + this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange); + this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset); + this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration); + this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration); + this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange); + this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration); + + this.isInitialized = false; + console.log('๐Ÿงน Settings Manager destroyed'); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/templates/index.html b/vna_system/web_ui/templates/index.html index 85f8feb..bd361bd 100644 --- a/vna_system/web_ui/templates/index.html +++ b/vna_system/web_ui/templates/index.html @@ -31,6 +31,7 @@ + @@ -123,8 +124,125 @@

-

Settings

-

Settings panel will be implemented in future versions.

+
+

+ + VNA Settings +

+ + +
+

Configuration Presets

+

Select measurement configuration from available presets

+ +
+
+ +
+ + +
+
+ +
+
+
+ Current: + None +
+
+
+
+
+ + +
+

Calibration Management

+

Manage calibration data for accurate measurements

+ + +
+
+ Current Calibration: + None +
+
+ + +
+ +
+

New Calibration

+
+ +
+
+ + + + + +
+

Existing Calibrations

+
+ + +
+
+
+
+ + +
+

Status Summary

+
+
+ Available Presets: + - +
+
+ Available Calibrations: + - +
+
+ System Status: + Checking... +
+
+
+