some fixes
This commit is contained in:
@ -1 +1 @@
|
||||
config_inputs/s11_start100_stop8800_points1000_bw1khz.bin
|
||||
config_inputs/s21_start100_stop8800_points1000_bw1khz.bin
|
||||
@ -14,7 +14,7 @@ API_PORT = 8000
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging settings (используются из main)
|
||||
# -----------------------------------------------------------------------------
|
||||
LOG_LEVEL = "INFO" # {"DEBUG","INFO","WARNING","ERROR","CRITICAL"}
|
||||
LOG_LEVEL = "DEBUG" # {"DEBUG","INFO","WARNING","ERROR","CRITICAL"}
|
||||
LOG_DIR = BASE_DIR / "logs" # Directory for application logs
|
||||
LOG_APP_FILE = LOG_DIR / "vna_system.log" # Main application log file
|
||||
|
||||
@ -74,3 +74,8 @@ SERIAL_DRAIN_CHECK_DELAY = 0.01
|
||||
SERIAL_CONNECT_DELAY = 0.01
|
||||
|
||||
PROCESSORS_CONFIG_DIR_PATH = "vna_system/core/processors/configs"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Physical constants
|
||||
# -----------------------------------------------------------------------------
|
||||
SPEED_OF_LIGHT_M_S = 299_700_000.0 # Speed of light in meters per second
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"open_air": true,
|
||||
"axis": "real",
|
||||
"data_limitation": null,
|
||||
"cut": 0.824,
|
||||
"max": 1.1,
|
||||
"axis": "abs",
|
||||
"data_limitation": "ph_only_1",
|
||||
"cut": 0.945,
|
||||
"max": 1.4,
|
||||
"gain": 1.2,
|
||||
"start_freq": 100.0,
|
||||
"stop_freq": 8800.0,
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
{
|
||||
"y_min": -90,
|
||||
"y_min": -55,
|
||||
"y_max": 20,
|
||||
"smoothing_enabled": true,
|
||||
"smoothing_window": 17,
|
||||
"marker_enabled": true,
|
||||
"marker_frequency": 300000009.0,
|
||||
"grid_enabled": true,
|
||||
"reset_smoothing": false
|
||||
"show_phase": true
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
from .magnitude_processor import MagnitudeProcessor
|
||||
from .phase_processor import PhaseProcessor
|
||||
from .smith_chart_processor import SmithChartProcessor
|
||||
|
||||
__all__ = ['MagnitudeProcessor', 'PhaseProcessor', 'SmithChartProcessor']
|
||||
@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from vna_system.core.logging.logger import get_component_logger
|
||||
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter
|
||||
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter, ProcessedResult
|
||||
from vna_system.core.acquisition.sweep_buffer import SweepData
|
||||
from vna_system.core.config import SPEED_OF_LIGHT_M_S
|
||||
|
||||
logger = get_component_logger(__file__)
|
||||
|
||||
@ -26,18 +27,15 @@ class BScanProcessor(BaseProcessor):
|
||||
- Plot history accumulation for multi-sweep heatmaps
|
||||
"""
|
||||
|
||||
# Physical and display-related constants
|
||||
SPEED_OF_LIGHT_M_S: Final[float] = 299_700_000.0 # m/s
|
||||
|
||||
def __init__(self, config_dir: Path) -> None:
|
||||
super().__init__("bscan", config_dir)
|
||||
|
||||
# Increase history size for multi-sweep plotting
|
||||
self.max_history = 10
|
||||
self._max_history = 50
|
||||
|
||||
# Local plot history (separate from sweep history maintained by BaseProcessor)
|
||||
self._plot_history: list[dict[str, Any]] = []
|
||||
self._max_plot_history = 50
|
||||
|
||||
logger.info("BScanProcessor initialized", processor_id=self.processor_id)
|
||||
|
||||
@ -141,7 +139,9 @@ class BScanProcessor(BaseProcessor):
|
||||
def _clear_plot_history(self) -> None:
|
||||
"""Clear the accumulated plot history."""
|
||||
with self._lock:
|
||||
self._plot_history.clear()
|
||||
latest = self._sweep_history[-1]
|
||||
self._sweep_history.clear()
|
||||
self._sweep_history.append(latest)
|
||||
logger.info("Plot history cleared", processor_id=self.processor_id)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@ -204,8 +204,8 @@ class BScanProcessor(BaseProcessor):
|
||||
|
||||
with self._lock:
|
||||
self._plot_history.append(plot_record)
|
||||
if len(self._plot_history) > self._max_plot_history:
|
||||
self._plot_history = self._plot_history[-self._max_plot_history :]
|
||||
if len(self._plot_history) > self._max_history:
|
||||
self._plot_history = self._plot_history[-self._max_history :]
|
||||
|
||||
return {
|
||||
"time_domain_data": analysis["time_data"].tolist(),
|
||||
@ -327,13 +327,65 @@ class BScanProcessor(BaseProcessor):
|
||||
"xaxis": {"title": "Sweep Number", "side": "top"},
|
||||
"yaxis": {"title": "Depth (m)", "autorange": "reversed"},
|
||||
"hovermode": "closest",
|
||||
"height": 600,
|
||||
"width": 500,
|
||||
"height": 546,
|
||||
"template": "plotly_dark",
|
||||
"margin": {"t": 30, "r": 50, "b": 50, "l": 50},
|
||||
"autosize": True
|
||||
}
|
||||
|
||||
return {"data": [heatmap_trace], "layout": layout}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Recalculation override
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def recalculate(self) -> ProcessedResult | None:
|
||||
"""
|
||||
Recompute all plot history using current config settings.
|
||||
|
||||
Unlike the base implementation that processes only the latest sweep,
|
||||
this method processes all sweep history to rebuild the complete heatmap.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._sweep_history:
|
||||
logger.debug("Recalculate skipped; sweep history empty")
|
||||
return None
|
||||
|
||||
# Clear existing plot history to rebuild from scratch
|
||||
self._plot_history.clear()
|
||||
|
||||
# Process all sweeps in history with current config
|
||||
for entry in self._sweep_history:
|
||||
sweep_data = entry["sweep_data"]
|
||||
calibrated_data = entry["calibrated_data"]
|
||||
vna_config = entry["vna_config"]
|
||||
|
||||
# Use process_sweep to handle the processing logic
|
||||
processed = self.process_sweep(sweep_data, calibrated_data, vna_config)
|
||||
|
||||
# Skip if processing failed
|
||||
if "error" in processed:
|
||||
continue
|
||||
|
||||
# Trim plot history if needed
|
||||
if len(self._plot_history) > self._max_history:
|
||||
self._plot_history = self._plot_history[-self._max_history:]
|
||||
|
||||
logger.info("Recalculated B-scan with all history",
|
||||
plot_records=len(self._plot_history),
|
||||
sweep_records=len(self._sweep_history))
|
||||
|
||||
# Return the result based on the last sweep processed
|
||||
if self._sweep_history:
|
||||
latest = self._sweep_history[-1]
|
||||
return self._process_data(
|
||||
latest["sweep_data"],
|
||||
latest["calibrated_data"],
|
||||
latest["vna_config"]
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Low-level helpers
|
||||
# -------------------------------------------------------------------------
|
||||
@ -502,7 +554,7 @@ class BScanProcessor(BaseProcessor):
|
||||
y = np.fft.ifft(H)
|
||||
|
||||
# Convert time (s) to one-way distance (m): d = c * t
|
||||
depth_m = t_sec * self.SPEED_OF_LIGHT_M_S
|
||||
depth_m = t_sec * SPEED_OF_LIGHT_M_S
|
||||
|
||||
if axis == "abs":
|
||||
y_fin = np.abs(y)
|
||||
|
||||
@ -16,19 +16,16 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
--------
|
||||
1) Derive frequency axis from VNA config (start/stop, N points).
|
||||
2) Compute |S| in dB per point (20*log10(|complex|), clamped for |complex|==0).
|
||||
3) Optionally smooth using moving-average (odd window).
|
||||
4) Provide Plotly configuration including an optional marker.
|
||||
3) Generate Plotly configuration with parameter type in legend.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- `calibrated_data` is expected to be a `SweepData` with `.points: list[tuple[float,float]]`.
|
||||
- Marker frequency is validated by BaseProcessor via `UIParameter(options=...)`.
|
||||
- Parameter type (S11, S21, etc.) is extracted from VNA config for proper labeling.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Path) -> None:
|
||||
super().__init__("magnitude", config_dir)
|
||||
# Internal state that can be reset via a UI "button"
|
||||
self._smoothing_history: list[float] = []
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core processing
|
||||
@ -61,8 +58,8 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
return {"error": "Empty calibrated sweep"}
|
||||
|
||||
# Frequency axis from VNA config (defaults if not provided)
|
||||
start_freq = float(vna_config.get("start_frequency", 100e6))
|
||||
stop_freq = float(vna_config.get("stop_frequency", 8.8e9))
|
||||
start_freq = float(vna_config.get("start_freq", 100e6))
|
||||
stop_freq = float(vna_config.get("stop_freq", 8.8e9))
|
||||
|
||||
if n == 1:
|
||||
freqs = [start_freq]
|
||||
@ -72,84 +69,94 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
|
||||
# Magnitude in dB (clamp zero magnitude to -120 dB)
|
||||
mags_db: list[float] = []
|
||||
for real, imag in points:
|
||||
mag = abs(complex(real, imag))
|
||||
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
|
||||
phases_deg: list[float] = []
|
||||
|
||||
# Optional smoothing
|
||||
if self._config.get("smoothing_enabled", False):
|
||||
window = int(self._config.get("smoothing_window", 5))
|
||||
mags_db = self._apply_moving_average(mags_db, window)
|
||||
for real, imag in points:
|
||||
complex_val = complex(real, imag)
|
||||
mag = abs(complex_val)
|
||||
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
|
||||
phases_deg.append(np.degrees(np.angle(complex_val)))
|
||||
|
||||
result = {
|
||||
"frequencies": freqs,
|
||||
"magnitudes_db": mags_db,
|
||||
"phases_deg": phases_deg,
|
||||
"y_min": float(self._config.get("y_min", -80)),
|
||||
"y_max": float(self._config.get("y_max", 10)),
|
||||
"marker_enabled": bool(self._config.get("marker_enabled", True)),
|
||||
"marker_frequency": float(
|
||||
self._config.get("marker_frequency", freqs[len(freqs) // 2] if freqs else 1e9)
|
||||
),
|
||||
"grid_enabled": bool(self._config.get("grid_enabled", True)),
|
||||
"show_phase": bool(self._config.get("show_phase", False)),
|
||||
}
|
||||
logger.debug("Magnitude sweep processed", points=n)
|
||||
return result
|
||||
|
||||
def generate_plotly_config(self, processed_data: dict[str, Any], vna_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Build a Plotly figure config for the magnitude trace and optional marker.
|
||||
Build a Plotly figure config for the magnitude trace and optional phase.
|
||||
"""
|
||||
if "error" in processed_data:
|
||||
return {"error": processed_data["error"]}
|
||||
|
||||
freqs: list[float] = processed_data["frequencies"]
|
||||
mags_db: list[float] = processed_data["magnitudes_db"]
|
||||
grid_enabled: bool = processed_data["grid_enabled"]
|
||||
phases_deg: list[float] = processed_data["phases_deg"]
|
||||
show_phase: bool = processed_data["show_phase"]
|
||||
|
||||
# Marker resolution
|
||||
marker_freq: float = processed_data["marker_frequency"]
|
||||
if freqs:
|
||||
idx = min(range(len(freqs)), key=lambda i: abs(freqs[i] - marker_freq))
|
||||
marker_mag = mags_db[idx]
|
||||
marker_x = freqs[idx] / 1e9
|
||||
marker_trace = {
|
||||
"x": [marker_x],
|
||||
"y": [marker_mag],
|
||||
"type": "scatter",
|
||||
"mode": "markers",
|
||||
"name": f"Marker: {freqs[idx]/1e9:.3f} GHz, {marker_mag:.2f} dB",
|
||||
"marker": {"color": "red", "size": 8, "symbol": "circle"},
|
||||
}
|
||||
else:
|
||||
idx = 0
|
||||
marker_trace = None
|
||||
# Determine the parameter type from preset mode
|
||||
parameter_type = vna_config["mode"].value.upper() # Convert "s11" -> "S11", "s21" -> "S21"
|
||||
|
||||
# Convert Hz to GHz for x-axis
|
||||
freqs_ghz = [f / 1e9 for f in freqs]
|
||||
|
||||
traces = [
|
||||
{
|
||||
"x": [f / 1e9 for f in freqs], # Hz -> GHz
|
||||
"x": freqs_ghz,
|
||||
"y": mags_db,
|
||||
"type": "scatter",
|
||||
"mode": "lines",
|
||||
"name": "Magnitude",
|
||||
"name": f"|{parameter_type}| Magnitude",
|
||||
"line": {"color": "blue", "width": 2},
|
||||
"yaxis": "y",
|
||||
}
|
||||
]
|
||||
if processed_data["marker_enabled"] and marker_trace:
|
||||
traces.append(marker_trace)
|
||||
|
||||
fig = {
|
||||
"data": traces,
|
||||
"layout": {
|
||||
"title": "Magnitude Response",
|
||||
"xaxis": {"title": "Frequency (GHz)", "showgrid": grid_enabled},
|
||||
# Add phase trace if enabled
|
||||
if show_phase:
|
||||
traces.append({
|
||||
"x": freqs_ghz,
|
||||
"y": phases_deg,
|
||||
"type": "scatter",
|
||||
"mode": "lines",
|
||||
"name": f"∠{parameter_type} Phase",
|
||||
"line": {"color": "red", "width": 2},
|
||||
"yaxis": "y2",
|
||||
})
|
||||
|
||||
# Layout configuration
|
||||
layout = {
|
||||
"title": f"{parameter_type} Response",
|
||||
"xaxis": {"title": "Frequency (GHz)", "showgrid": True},
|
||||
"yaxis": {
|
||||
"title": "Magnitude (dB)",
|
||||
"range": [processed_data["y_min"], processed_data["y_max"]],
|
||||
"showgrid": grid_enabled,
|
||||
"showgrid": True,
|
||||
"side": "left",
|
||||
},
|
||||
"hovermode": "x unified",
|
||||
"showlegend": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Add second y-axis for phase if enabled
|
||||
if show_phase:
|
||||
layout["yaxis2"] = {
|
||||
"title": "Phase (°)",
|
||||
"overlaying": "y",
|
||||
"side": "right",
|
||||
"showgrid": False,
|
||||
"range": [-180, 180],
|
||||
}
|
||||
|
||||
fig = {
|
||||
"data": traces,
|
||||
"layout": layout,
|
||||
}
|
||||
return fig
|
||||
|
||||
@ -158,13 +165,7 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
# ------------------------------------------------------------------ #
|
||||
def get_ui_parameters(self) -> list[UIParameter]:
|
||||
"""
|
||||
UI/validation schema.
|
||||
|
||||
Conforms to BaseProcessor rules:
|
||||
- slider: requires dtype + min/max/step alignment checks
|
||||
- toggle: bool only
|
||||
- input: numeric only, with {"type": "int"|"float", "min"?, "max"?}
|
||||
- button: {"action": "..."}; value ignored by validation
|
||||
UI/validation schema for magnitude processor.
|
||||
"""
|
||||
return [
|
||||
UIParameter(
|
||||
@ -172,56 +173,20 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
label="Y Axis Min (dB)",
|
||||
type="slider",
|
||||
value=self._config.get("y_min", -80),
|
||||
options={"min": -120, "max": 0, "step": 5, "dtype": "int"},
|
||||
options={"min": -80, "max": 20, "step": 5, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="y_max",
|
||||
label="Y Axis Max (dB)",
|
||||
type="slider",
|
||||
value=self._config.get("y_max", 10),
|
||||
options={"min": -20, "max": 20, "step": 5, "dtype": "int"},
|
||||
options={"min": -20, "max": 40, "step": 5, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="smoothing_enabled",
|
||||
label="Enable Smoothing",
|
||||
name="show_phase",
|
||||
label="Show Phase",
|
||||
type="toggle",
|
||||
value=self._config.get("smoothing_enabled", False),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="smoothing_window",
|
||||
label="Smoothing Window Size",
|
||||
type="slider",
|
||||
value=self._config.get("smoothing_window", 5),
|
||||
options={"min": 3, "max": 21, "step": 2, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="marker_enabled",
|
||||
label="Show Marker",
|
||||
type="toggle",
|
||||
value=self._config.get("marker_enabled", True),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="marker_frequency",
|
||||
label="Marker Frequency (Hz)",
|
||||
type="input",
|
||||
value=self._config.get("marker_frequency", 1e9),
|
||||
options={"type": "float", "min": 100e6, "max": 8.8e9},
|
||||
),
|
||||
UIParameter(
|
||||
name="grid_enabled",
|
||||
label="Show Grid",
|
||||
type="toggle",
|
||||
value=self._config.get("grid_enabled", True),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="reset_smoothing",
|
||||
label="Reset Smoothing",
|
||||
type="button",
|
||||
value=False, # buttons carry no state; ignored by validator
|
||||
options={"action": "Reset the smoothing filter state"},
|
||||
value=self._config.get("show_phase", False),
|
||||
),
|
||||
]
|
||||
|
||||
@ -230,64 +195,34 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
return {
|
||||
"y_min": -80,
|
||||
"y_max": 10,
|
||||
"smoothing_enabled": False,
|
||||
"smoothing_window": 5,
|
||||
"marker_enabled": True,
|
||||
"marker_frequency": 1e9,
|
||||
"grid_enabled": True,
|
||||
"show_phase": False,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Config updates & actions
|
||||
# ------------------------------------------------------------------ #
|
||||
def update_config(self, updates: dict[str, Any]) -> None:
|
||||
"""
|
||||
Apply config updates; handle UI buttons out-of-band.
|
||||
|
||||
Any key that corresponds to a button triggers an action when True and
|
||||
is *not* persisted in the config. Other keys are forwarded to the
|
||||
BaseProcessor (with type conversion + validation).
|
||||
Update configuration with validation to ensure y_max > y_min.
|
||||
"""
|
||||
ui_params = {param.name: param for param in self.get_ui_parameters()}
|
||||
button_actions: dict[str, bool] = {}
|
||||
config_updates: dict[str, Any] = {}
|
||||
# Apply base update first
|
||||
super().update_config(updates)
|
||||
|
||||
for key, value in updates.items():
|
||||
schema = ui_params.get(key)
|
||||
if schema and schema.type == "button":
|
||||
if value:
|
||||
button_actions[key] = True
|
||||
else:
|
||||
config_updates[key] = value
|
||||
# Validate y_max > y_min after update
|
||||
y_min = float(self._config.get("y_min", -80))
|
||||
y_max = float(self._config.get("y_max", 10))
|
||||
|
||||
if config_updates:
|
||||
super().update_config(config_updates)
|
||||
if y_max <= y_min:
|
||||
# Adjust y_max to be at least 5 dB above y_min
|
||||
new_y_max = y_min + 5
|
||||
# Clamp to maximum allowed value
|
||||
max_allowed = 40
|
||||
if new_y_max > max_allowed:
|
||||
new_y_max = max_allowed
|
||||
# If we hit the ceiling, adjust y_min instead
|
||||
if new_y_max <= y_min:
|
||||
self._config["y_min"] = new_y_max - 5
|
||||
|
||||
# Execute button actions
|
||||
if button_actions.get("reset_smoothing"):
|
||||
self._smoothing_history.clear()
|
||||
logger.info("Smoothing state reset via UI button")
|
||||
self._config["y_max"] = new_y_max
|
||||
self.save_config()
|
||||
logger.info("Adjusted y_max to maintain y_max > y_min",
|
||||
y_min=self._config["y_min"],
|
||||
y_max=self._config["y_max"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Smoothing
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def _apply_moving_average(data: list[float], window_size: int) -> list[float]:
|
||||
"""
|
||||
Centered moving average with clamped edges.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
- window_size must be odd and >= 3 (enforced by UI schema).
|
||||
"""
|
||||
n = len(data)
|
||||
if n == 0 or window_size <= 1 or window_size >= n:
|
||||
return data
|
||||
|
||||
half = window_size // 2
|
||||
out: list[float] = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
out.append(sum(data[lo:hi]) / (hi - lo))
|
||||
return out
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from vna_system.core.logging.logger import get_component_logger
|
||||
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter
|
||||
|
||||
logger = get_component_logger(__file__)
|
||||
|
||||
|
||||
class PhaseProcessor(BaseProcessor):
|
||||
"""
|
||||
Compute and visualize phase (degrees) over frequency from calibrated sweep data.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1) Derive frequency axis from VNA config (start/stop, N points).
|
||||
2) Compute phase in degrees from complex samples.
|
||||
3) Optional simple unwrapping (+/-180° jumps) and offset.
|
||||
4) Optional moving-average smoothing.
|
||||
5) Provide Plotly configuration including an optional marker and reference line.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Path) -> None:
|
||||
super().__init__("phase", config_dir)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core processing
|
||||
# ------------------------------------------------------------------ #
|
||||
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Produce phase trace (degrees) and ancillary info from a calibrated sweep.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict[str, Any]
|
||||
{
|
||||
'frequencies': list[float],
|
||||
'phases_deg': list[float],
|
||||
'y_min': float,
|
||||
'y_max': float,
|
||||
'marker_enabled': bool,
|
||||
'marker_frequency': float,
|
||||
'grid_enabled': bool,
|
||||
'reference_line_enabled': bool,
|
||||
'reference_phase': float
|
||||
}
|
||||
"""
|
||||
if not calibrated_data or not hasattr(calibrated_data, "points"):
|
||||
logger.warning("No calibrated data available for phase processing")
|
||||
return {"error": "No calibrated data available"}
|
||||
|
||||
points: list[tuple[float, float]] = calibrated_data.points
|
||||
n = len(points)
|
||||
if n == 0:
|
||||
logger.warning("Calibrated sweep contains zero points")
|
||||
return {"error": "Empty calibrated sweep"}
|
||||
|
||||
# Frequency axis from VNA config (defaults if not provided)
|
||||
start_freq = float(vna_config.get("start_frequency", 100e6))
|
||||
stop_freq = float(vna_config.get("stop_frequency", 8.8e9))
|
||||
|
||||
if n == 1:
|
||||
freqs = [start_freq]
|
||||
else:
|
||||
step = (stop_freq - start_freq) / (n - 1)
|
||||
freqs = [start_freq + i * step for i in range(n)]
|
||||
|
||||
# Phase in degrees
|
||||
phases_deg: list[float] = []
|
||||
unwrap = bool(self._config.get("unwrap_phase", True))
|
||||
|
||||
for i, (real, imag) in enumerate(points):
|
||||
z = complex(real, imag)
|
||||
deg = float(np.degrees(np.angle(z)))
|
||||
|
||||
if unwrap and phases_deg:
|
||||
diff = deg - phases_deg[-1]
|
||||
if diff > 180.0:
|
||||
deg -= 360.0
|
||||
elif diff < -180.0:
|
||||
deg += 360.0
|
||||
|
||||
phases_deg.append(deg)
|
||||
|
||||
# Offset
|
||||
phase_offset = float(self._config.get("phase_offset", 0.0))
|
||||
if phase_offset:
|
||||
phases_deg = [p + phase_offset for p in phases_deg]
|
||||
|
||||
# Optional smoothing
|
||||
if self._config.get("smoothing_enabled", False):
|
||||
window = int(self._config.get("smoothing_window", 5))
|
||||
phases_deg = self._apply_moving_average(phases_deg, window)
|
||||
|
||||
result = {
|
||||
"frequencies": freqs,
|
||||
"phases_deg": phases_deg,
|
||||
"y_min": float(self._config.get("y_min", -180)),
|
||||
"y_max": float(self._config.get("y_max", 180)),
|
||||
"marker_enabled": bool(self._config.get("marker_enabled", True)),
|
||||
"marker_frequency": float(
|
||||
self._config.get("marker_frequency", freqs[len(freqs) // 2] if freqs else 1e9)
|
||||
),
|
||||
"grid_enabled": bool(self._config.get("grid_enabled", True)),
|
||||
"reference_line_enabled": bool(self._config.get("reference_line_enabled", False)),
|
||||
"reference_phase": float(self._config.get("reference_phase", 0)),
|
||||
}
|
||||
logger.debug("Phase sweep processed", points=n)
|
||||
return result
|
||||
|
||||
def generate_plotly_config(self, processed_data: dict[str, Any], vna_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Build a Plotly figure config for the phase trace, marker, and optional reference line.
|
||||
"""
|
||||
if "error" in processed_data:
|
||||
return {"error": processed_data["error"]}
|
||||
|
||||
freqs: list[float] = processed_data["frequencies"]
|
||||
phases: list[float] = processed_data["phases_deg"]
|
||||
grid_enabled: bool = processed_data["grid_enabled"]
|
||||
|
||||
# Marker
|
||||
marker_freq: float = processed_data["marker_frequency"]
|
||||
if freqs:
|
||||
idx = min(range(len(freqs)), key=lambda i: abs(freqs[i] - marker_freq))
|
||||
marker_y = phases[idx]
|
||||
marker_trace = {
|
||||
"x": [freqs[idx] / 1e9],
|
||||
"y": [marker_y],
|
||||
"type": "scatter",
|
||||
"mode": "markers",
|
||||
"name": f"Marker: {freqs[idx]/1e9:.3f} GHz, {marker_y:.1f}°",
|
||||
"marker": {"color": "red", "size": 8, "symbol": "circle"},
|
||||
}
|
||||
else:
|
||||
marker_trace = None
|
||||
|
||||
traces = [
|
||||
{
|
||||
"x": [f / 1e9 for f in freqs], # Hz -> GHz
|
||||
"y": phases,
|
||||
"type": "scatter",
|
||||
"mode": "lines",
|
||||
"name": "Phase",
|
||||
"line": {"color": "green", "width": 2},
|
||||
}
|
||||
]
|
||||
if processed_data["marker_enabled"] and marker_trace:
|
||||
traces.append(marker_trace)
|
||||
|
||||
# Reference line
|
||||
if processed_data["reference_line_enabled"] and freqs:
|
||||
ref = float(processed_data["reference_phase"])
|
||||
traces.append(
|
||||
{
|
||||
"x": [freqs[0] / 1e9, freqs[-1] / 1e9],
|
||||
"y": [ref, ref],
|
||||
"type": "scatter",
|
||||
"mode": "lines",
|
||||
"name": f"Reference: {ref:.1f}°",
|
||||
"line": {"color": "gray", "width": 1, "dash": "dash"},
|
||||
}
|
||||
)
|
||||
|
||||
fig = {
|
||||
"data": traces,
|
||||
"layout": {
|
||||
"title": "Phase Response",
|
||||
"xaxis": {"title": "Frequency (GHz)", "showgrid": grid_enabled},
|
||||
"yaxis": {
|
||||
"title": "Phase (degrees)",
|
||||
"range": [processed_data["y_min"], processed_data["y_max"]],
|
||||
"showgrid": grid_enabled,
|
||||
},
|
||||
"hovermode": "x unified",
|
||||
"showlegend": True,
|
||||
},
|
||||
}
|
||||
return fig
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UI schema
|
||||
# ------------------------------------------------------------------ #
|
||||
def get_ui_parameters(self) -> list[UIParameter]:
|
||||
"""
|
||||
UI/validation schema (compatible with BaseProcessor's validators).
|
||||
"""
|
||||
return [
|
||||
UIParameter(
|
||||
name="y_min",
|
||||
label="Y Axis Min (degrees)",
|
||||
type="slider",
|
||||
value=self._config.get("y_min", -180),
|
||||
options={"min": -360, "max": 0, "step": 15, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="y_max",
|
||||
label="Y Axis Max (degrees)",
|
||||
type="slider",
|
||||
value=self._config.get("y_max", 180),
|
||||
options={"min": 0, "max": 360, "step": 15, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="unwrap_phase",
|
||||
label="Unwrap Phase",
|
||||
type="toggle",
|
||||
value=self._config.get("unwrap_phase", True),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="phase_offset",
|
||||
label="Phase Offset (degrees)",
|
||||
type="slider",
|
||||
value=self._config.get("phase_offset", 0),
|
||||
options={"min": -180, "max": 180, "step": 5, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="smoothing_enabled",
|
||||
label="Enable Smoothing",
|
||||
type="toggle",
|
||||
value=self._config.get("smoothing_enabled", False),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="smoothing_window",
|
||||
label="Smoothing Window Size",
|
||||
type="slider",
|
||||
value=self._config.get("smoothing_window", 5),
|
||||
options={"min": 3, "max": 21, "step": 2, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="marker_enabled",
|
||||
label="Show Marker",
|
||||
type="toggle",
|
||||
value=self._config.get("marker_enabled", True),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="marker_frequency",
|
||||
label="Marker Frequency (Hz)",
|
||||
type="input",
|
||||
value=self._config.get("marker_frequency", 1e9),
|
||||
options={"type": "float", "min": 100e6, "max": 8.8e9},
|
||||
),
|
||||
UIParameter(
|
||||
name="reference_line_enabled",
|
||||
label="Show Reference Line",
|
||||
type="toggle",
|
||||
value=self._config.get("reference_line_enabled", False),
|
||||
options={},
|
||||
),
|
||||
UIParameter(
|
||||
name="reference_phase",
|
||||
label="Reference Phase (degrees)",
|
||||
type="slider",
|
||||
value=self._config.get("reference_phase", 0),
|
||||
options={"min": -180, "max": 180, "step": 15, "dtype": "int"},
|
||||
),
|
||||
UIParameter(
|
||||
name="grid_enabled",
|
||||
label="Show Grid",
|
||||
type="toggle",
|
||||
value=self._config.get("grid_enabled", True),
|
||||
options={},
|
||||
),
|
||||
]
|
||||
|
||||
def _get_default_config(self) -> dict[str, Any]:
|
||||
"""Defaults aligned with the UI schema."""
|
||||
return {
|
||||
"y_min": -180,
|
||||
"y_max": 180,
|
||||
"unwrap_phase": True,
|
||||
"phase_offset": 0,
|
||||
"smoothing_enabled": False,
|
||||
"smoothing_window": 5,
|
||||
"marker_enabled": True,
|
||||
"marker_frequency": 1e9,
|
||||
"reference_line_enabled": False,
|
||||
"reference_phase": 0,
|
||||
"grid_enabled": True,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Smoothing
|
||||
# ------------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
def _apply_moving_average(data: list[float], window_size: int) -> list[float]:
|
||||
"""
|
||||
Centered moving average with clamped edges.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
- `window_size` must be odd and >= 3 (enforced by UI schema).
|
||||
"""
|
||||
n = len(data)
|
||||
if n == 0 or window_size <= 1 or window_size >= n:
|
||||
return data
|
||||
|
||||
half = window_size // 2
|
||||
out: list[float] = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
out.append(sum(data[lo:hi]) / (hi - lo))
|
||||
return out
|
||||
@ -1,303 +0,0 @@
|
||||
import numpy as np
|
||||
from typing import Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
from ..base_processor import BaseProcessor, UIParameter
|
||||
|
||||
|
||||
class SmithChartProcessor(BaseProcessor):
|
||||
def __init__(self, config_dir: Path):
|
||||
super().__init__("smith_chart", config_dir)
|
||||
|
||||
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not calibrated_data or not hasattr(calibrated_data, 'points'):
|
||||
return {'error': 'No calibrated data available'}
|
||||
|
||||
frequencies = []
|
||||
real_parts = []
|
||||
imag_parts = []
|
||||
reflection_coeffs = []
|
||||
|
||||
for i, (real, imag) in enumerate(calibrated_data.points):
|
||||
complex_val = complex(real, imag)
|
||||
|
||||
# Calculate frequency
|
||||
start_freq = vna_config.get('start_frequency', 100e6)
|
||||
stop_freq = vna_config.get('stop_frequency', 8.8e9)
|
||||
total_points = len(calibrated_data.points)
|
||||
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
|
||||
|
||||
frequencies.append(frequency)
|
||||
real_parts.append(real)
|
||||
imag_parts.append(imag)
|
||||
reflection_coeffs.append(complex_val)
|
||||
|
||||
# Convert to impedance if requested
|
||||
impedance_mode = self._config.get('impedance_mode', False)
|
||||
z0 = self._config.get('reference_impedance', 50)
|
||||
|
||||
if impedance_mode:
|
||||
impedances = [(z0 * (1 + gamma) / (1 - gamma)) for gamma in reflection_coeffs]
|
||||
plot_real = [z.real for z in impedances]
|
||||
plot_imag = [z.imag for z in impedances]
|
||||
else:
|
||||
plot_real = real_parts
|
||||
plot_imag = imag_parts
|
||||
|
||||
return {
|
||||
'frequencies': frequencies,
|
||||
'real_parts': plot_real,
|
||||
'imag_parts': plot_imag,
|
||||
'impedance_mode': impedance_mode,
|
||||
'reference_impedance': z0,
|
||||
'marker_enabled': self._config.get('marker_enabled', True),
|
||||
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
|
||||
'grid_circles': self._config.get('grid_circles', True),
|
||||
'grid_radials': self._config.get('grid_radials', True),
|
||||
'trace_color_mode': self._config.get('trace_color_mode', 'frequency')
|
||||
}
|
||||
|
||||
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if 'error' in processed_data:
|
||||
return {'error': processed_data['error']}
|
||||
|
||||
real_parts = processed_data['real_parts']
|
||||
imag_parts = processed_data['imag_parts']
|
||||
frequencies = processed_data['frequencies']
|
||||
|
||||
# Find marker point
|
||||
marker_freq = processed_data['marker_frequency']
|
||||
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
|
||||
|
||||
# Create main trace
|
||||
trace_config = {
|
||||
'x': real_parts,
|
||||
'y': imag_parts,
|
||||
'type': 'scatter',
|
||||
'mode': 'lines+markers',
|
||||
'marker': {'size': 3},
|
||||
'line': {'width': 2}
|
||||
}
|
||||
|
||||
if processed_data['trace_color_mode'] == 'frequency':
|
||||
# Color by frequency
|
||||
trace_config['marker']['color'] = [f / 1e9 for f in frequencies]
|
||||
trace_config['marker']['colorscale'] = 'Viridis'
|
||||
trace_config['marker']['showscale'] = True
|
||||
trace_config['marker']['colorbar'] = {'title': 'Frequency (GHz)'}
|
||||
trace_config['name'] = 'S11 (colored by frequency)'
|
||||
else:
|
||||
trace_config['marker']['color'] = 'blue'
|
||||
trace_config['name'] = 'S11'
|
||||
|
||||
traces = [trace_config]
|
||||
|
||||
# Add marker if enabled
|
||||
if processed_data['marker_enabled']:
|
||||
marker_real = real_parts[marker_idx]
|
||||
marker_imag = imag_parts[marker_idx]
|
||||
|
||||
if processed_data['impedance_mode']:
|
||||
marker_label = f'Z = {marker_real:.1f} + j{marker_imag:.1f} Ω'
|
||||
else:
|
||||
marker_label = f'Γ = {marker_real:.3f} + j{marker_imag:.3f}'
|
||||
|
||||
traces.append({
|
||||
'x': [marker_real],
|
||||
'y': [marker_imag],
|
||||
'type': 'scatter',
|
||||
'mode': 'markers',
|
||||
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz<br>{marker_label}',
|
||||
'marker': {'color': 'red', 'size': 10, 'symbol': 'diamond'}
|
||||
})
|
||||
|
||||
# Add Smith chart grid if not in impedance mode
|
||||
if not processed_data['impedance_mode']:
|
||||
if processed_data['grid_circles']:
|
||||
traces.extend(self._generate_smith_circles())
|
||||
if processed_data['grid_radials']:
|
||||
traces.extend(self._generate_smith_radials())
|
||||
|
||||
# Layout configuration
|
||||
if processed_data['impedance_mode']:
|
||||
layout = {
|
||||
'title': f'Impedance Plot (Z₀ = {processed_data["reference_impedance"]} Ω)',
|
||||
'xaxis': {'title': 'Resistance (Ω)', 'showgrid': True, 'zeroline': True},
|
||||
'yaxis': {'title': 'Reactance (Ω)', 'showgrid': True, 'zeroline': True, 'scaleanchor': 'x'},
|
||||
'hovermode': 'closest'
|
||||
}
|
||||
else:
|
||||
layout = {
|
||||
'title': 'Smith Chart',
|
||||
'xaxis': {
|
||||
'title': 'Real Part',
|
||||
'range': [-1.1, 1.1],
|
||||
'showgrid': False,
|
||||
'zeroline': False,
|
||||
'showticklabels': True
|
||||
},
|
||||
'yaxis': {
|
||||
'title': 'Imaginary Part',
|
||||
'range': [-1.1, 1.1],
|
||||
'showgrid': False,
|
||||
'zeroline': False,
|
||||
'scaleanchor': 'x',
|
||||
'scaleratio': 1
|
||||
},
|
||||
'hovermode': 'closest'
|
||||
}
|
||||
|
||||
layout['showlegend'] = True
|
||||
|
||||
return {
|
||||
'data': traces,
|
||||
'layout': layout
|
||||
}
|
||||
|
||||
def _generate_smith_circles(self) -> List[Dict[str, Any]]:
|
||||
circles = []
|
||||
# Constant resistance circles
|
||||
for r in [0, 0.2, 0.5, 1, 2, 5]:
|
||||
if r == 0:
|
||||
# Unit circle
|
||||
theta = np.linspace(0, 2*np.pi, 100)
|
||||
x = np.cos(theta)
|
||||
y = np.sin(theta)
|
||||
else:
|
||||
center_x = r / (r + 1)
|
||||
radius = 1 / (r + 1)
|
||||
theta = np.linspace(0, 2*np.pi, 100)
|
||||
x = center_x + radius * np.cos(theta)
|
||||
y = radius * np.sin(theta)
|
||||
|
||||
circles.append({
|
||||
'x': x.tolist(),
|
||||
'y': y.tolist(),
|
||||
'type': 'scatter',
|
||||
'mode': 'lines',
|
||||
'line': {'color': 'lightgray', 'width': 1},
|
||||
'showlegend': False,
|
||||
'hoverinfo': 'skip'
|
||||
})
|
||||
|
||||
# Constant reactance circles
|
||||
for x in [-5, -2, -1, -0.5, -0.2, 0.2, 0.5, 1, 2, 5]:
|
||||
if abs(x) < 1e-10:
|
||||
continue
|
||||
center_y = 1/x
|
||||
radius = abs(1/x)
|
||||
theta = np.linspace(-np.pi, np.pi, 100)
|
||||
circle_x = 1 + radius * np.cos(theta)
|
||||
circle_y = center_y + radius * np.sin(theta)
|
||||
|
||||
# Clip to unit circle
|
||||
valid_points = circle_x**2 + circle_y**2 <= 1.01
|
||||
circle_x = circle_x[valid_points]
|
||||
circle_y = circle_y[valid_points]
|
||||
|
||||
if len(circle_x) > 0:
|
||||
circles.append({
|
||||
'x': circle_x.tolist(),
|
||||
'y': circle_y.tolist(),
|
||||
'type': 'scatter',
|
||||
'mode': 'lines',
|
||||
'line': {'color': 'lightblue', 'width': 1},
|
||||
'showlegend': False,
|
||||
'hoverinfo': 'skip'
|
||||
})
|
||||
|
||||
return circles
|
||||
|
||||
def _generate_smith_radials(self) -> List[Dict[str, Any]]:
|
||||
radials = []
|
||||
# Radial lines for constant phase
|
||||
for angle_deg in range(0, 360, 30):
|
||||
angle_rad = np.radians(angle_deg)
|
||||
x = [0, np.cos(angle_rad)]
|
||||
y = [0, np.sin(angle_rad)]
|
||||
|
||||
radials.append({
|
||||
'x': x,
|
||||
'y': y,
|
||||
'type': 'scatter',
|
||||
'mode': 'lines',
|
||||
'line': {'color': 'lightgray', 'width': 1, 'dash': 'dot'},
|
||||
'showlegend': False,
|
||||
'hoverinfo': 'skip'
|
||||
})
|
||||
|
||||
return radials
|
||||
|
||||
def get_ui_parameters(self) -> List[UIParameter]:
|
||||
return [
|
||||
UIParameter(
|
||||
name='impedance_mode',
|
||||
label='Impedance Plot Mode',
|
||||
type='toggle',
|
||||
value=self._config.get('impedance_mode', False)
|
||||
),
|
||||
UIParameter(
|
||||
name='reference_impedance',
|
||||
label='Reference Impedance (Ω)',
|
||||
type='select',
|
||||
value=self._config.get('reference_impedance', 50),
|
||||
options={'choices': [25, 50, 75, 100, 600]}
|
||||
),
|
||||
UIParameter(
|
||||
name='marker_enabled',
|
||||
label='Show Marker',
|
||||
type='toggle',
|
||||
value=self._config.get('marker_enabled', True)
|
||||
),
|
||||
UIParameter(
|
||||
name='marker_frequency',
|
||||
label='Marker Frequency (Hz)',
|
||||
type='input',
|
||||
value=self._config.get('marker_frequency', 1e9),
|
||||
options={'type': 'number', 'min': 100e6, 'max': 8.8e9}
|
||||
),
|
||||
UIParameter(
|
||||
name='grid_circles',
|
||||
label='Show Impedance Circles',
|
||||
type='toggle',
|
||||
value=self._config.get('grid_circles', True)
|
||||
),
|
||||
UIParameter(
|
||||
name='grid_radials',
|
||||
label='Show Phase Radials',
|
||||
type='toggle',
|
||||
value=self._config.get('grid_radials', True)
|
||||
),
|
||||
UIParameter(
|
||||
name='trace_color_mode',
|
||||
label='Trace Color Mode',
|
||||
type='select',
|
||||
value=self._config.get('trace_color_mode', 'frequency'),
|
||||
options={'choices': ['frequency', 'solid']}
|
||||
)
|
||||
]
|
||||
|
||||
def _get_default_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'impedance_mode': False,
|
||||
'reference_impedance': 50,
|
||||
'marker_enabled': True,
|
||||
'marker_frequency': 1e9,
|
||||
'grid_circles': True,
|
||||
'grid_radials': True,
|
||||
'trace_color_mode': 'frequency'
|
||||
}
|
||||
|
||||
def _validate_config(self):
|
||||
required_keys = ['impedance_mode', 'reference_impedance', 'marker_enabled',
|
||||
'marker_frequency', 'grid_circles', 'grid_radials', 'trace_color_mode']
|
||||
|
||||
for key in required_keys:
|
||||
if key not in self._config:
|
||||
raise ValueError(f"Missing required config key: {key}")
|
||||
|
||||
if self._config['reference_impedance'] <= 0:
|
||||
raise ValueError("reference_impedance must be positive")
|
||||
|
||||
if self._config['trace_color_mode'] not in ['frequency', 'solid']:
|
||||
raise ValueError("trace_color_mode must be 'frequency' or 'solid'")
|
||||
@ -267,11 +267,11 @@ class ProcessorManager:
|
||||
we log an error and keep going with whatever is available.
|
||||
"""
|
||||
try:
|
||||
from .implementations import MagnitudeProcessor, PhaseProcessor, SmithChartProcessor
|
||||
from .implementations.magnitude_processor import MagnitudeProcessor
|
||||
from .implementations.bscan_processor import BScanProcessor
|
||||
|
||||
self.register_processor(MagnitudeProcessor(self.config_dir))
|
||||
self.register_processor(PhaseProcessor(self.config_dir))
|
||||
# self.register_processor(PhaseProcessor(self.config_dir))
|
||||
self.register_processor(BScanProcessor(self.config_dir))
|
||||
# self.register_processor(SmithChartProcessor(self.config_dir))
|
||||
|
||||
|
||||
@ -84,6 +84,14 @@ class ProcessorWebSocketHandler:
|
||||
# --------------------------------------------------------------------- #
|
||||
async def handle_message(self, websocket: WebSocket, data: str) -> None:
|
||||
"""Parse and route an inbound client message."""
|
||||
# Handle ping/pong messages first (they are not JSON)
|
||||
if data == "ping":
|
||||
await websocket.send_text("pong")
|
||||
return
|
||||
elif data == "pong":
|
||||
# Just acknowledge, no response needed
|
||||
return
|
||||
|
||||
try:
|
||||
message = json.loads(data)
|
||||
mtype = message.get("type")
|
||||
@ -94,8 +102,15 @@ class ProcessorWebSocketHandler:
|
||||
await self._handle_get_history(websocket, message)
|
||||
else:
|
||||
await self._send_error(websocket, f"Unknown message type: {mtype!r}")
|
||||
except json.JSONDecodeError:
|
||||
await self._send_error(websocket, "Invalid JSON format")
|
||||
except json.JSONDecodeError as json_error:
|
||||
logger.error("JSON decode error", raw_data=data[:200], error=str(json_error))
|
||||
await self._send_error(
|
||||
websocket,
|
||||
"Invalid JSON format",
|
||||
details=f"JSON parse error: {json_error}",
|
||||
source="websocket_handler",
|
||||
raw_data=data[:200] if len(data) <= 200 else f"{data[:200]}..."
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error handling websocket message")
|
||||
await self._send_error(websocket, f"Internal error: {exc}")
|
||||
@ -170,7 +185,16 @@ class ProcessorWebSocketHandler:
|
||||
"metadata": result.metadata,
|
||||
}
|
||||
|
||||
async def _send_error(self, websocket: WebSocket, message: str) -> None:
|
||||
async def _send_error(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
message: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
source: str | None = None,
|
||||
code: str | None = None,
|
||||
raw_data: str | None = None
|
||||
) -> None:
|
||||
"""Send a standardized error payload to a single client."""
|
||||
try:
|
||||
payload = {
|
||||
@ -178,6 +202,17 @@ class ProcessorWebSocketHandler:
|
||||
"message": message,
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
}
|
||||
|
||||
# Add optional fields if provided
|
||||
if details:
|
||||
payload["details"] = details
|
||||
if source:
|
||||
payload["source"] = source
|
||||
if code:
|
||||
payload["code"] = code
|
||||
if raw_data:
|
||||
payload["raw_data"] = raw_data
|
||||
|
||||
await websocket.send_text(json.dumps(payload))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error sending error message", error=repr(exc))
|
||||
|
||||
@ -57,23 +57,38 @@
|
||||
.chart-card:fullscreen .chart-card__header {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.chart-card:fullscreen .chart-card__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row !important; /* Горизонтальное расположение вместо колонки */
|
||||
padding: var(--space-2);
|
||||
min-height: 0;
|
||||
height: calc(100vh - 80px); /* Вычитаем высоту header и meta */
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.chart-card:fullscreen .chart-card__plot {
|
||||
flex: 1 !important;
|
||||
width: 100% !important;
|
||||
width: calc(100% - 270px) !important; /* Учитываем ширину настроек + gap */
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.chart-card:fullscreen .chart-card__settings {
|
||||
width: 250px !important;
|
||||
flex-shrink: 0 !important;
|
||||
height: 100% !important; /* Занимает всю доступную высоту */
|
||||
max-height: none !important; /* Убираем ограничение высоты */
|
||||
overflow-y: auto !important;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.chart-card:fullscreen .chart-card__plot .js-plotly-plot {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
@ -84,9 +99,23 @@
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Plotly specific fullscreen adjustments */
|
||||
.chart-card:fullscreen .chart-card__plot .plotly {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Force Plotly to use full available height in fullscreen */
|
||||
.chart-card:fullscreen .chart-card__plot .js-plotly-plot,
|
||||
.chart-card:fullscreen .chart-card__plot .js-plotly-plot > div {
|
||||
height: calc(100vh - 120px) !important; /* Вычитаем высоту header + padding + meta */
|
||||
min-height: calc(100vh - 120px) !important;
|
||||
}
|
||||
|
||||
.chart-card:fullscreen .chart-card__meta {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* Chart loading state */
|
||||
|
||||
@ -276,14 +276,14 @@
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: var(--space-4);
|
||||
min-height: 450px;
|
||||
min-height: 585px; /* Увеличено на 30%: 450 * 1.3 = 585 */
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.chart-card__plot {
|
||||
flex: 1;
|
||||
height: 420px !important;
|
||||
min-height: 400px;
|
||||
height: 546px !important; /* Увеличено на 30%: 420 * 1.3 = 546 */
|
||||
min-height: 520px; /* Увеличено на 30%: 400 * 1.3 = 520 */
|
||||
position: relative;
|
||||
min-width: 0; /* Allows flex item to shrink */
|
||||
}
|
||||
@ -296,7 +296,7 @@
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
overflow-y: auto;
|
||||
max-height: 420px;
|
||||
max-height: 546px; /* Увеличено на 30%: 420 * 1.3 = 546 */
|
||||
}
|
||||
|
||||
.chart-card__plot .js-plotly-plot {
|
||||
@ -312,7 +312,7 @@
|
||||
/* Fix Plotly toolbar positioning with project design system */
|
||||
.chart-card__plot .modebar {
|
||||
position: absolute !important;
|
||||
top: var(--space-3) !important;
|
||||
top: 4px !important; /* Максимально высоко - минимальный отступ */
|
||||
right: var(--space-3) !important;
|
||||
z-index: 1000 !important;
|
||||
background: var(--bg-surface) !important;
|
||||
@ -381,6 +381,24 @@
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
/* Plotly title positioning to avoid overlapping with modebar */
|
||||
.chart-card__plot .gtitle {
|
||||
margin-top: -10px !important; /* Поднимаем заголовок на уровень кнопок управления */
|
||||
margin-bottom: 25px !important; /* Увеличенный отступ от подписей осей */
|
||||
}
|
||||
|
||||
/* Plotly chart area adjustment for better spacing */
|
||||
.chart-card__plot .plotly .main-svg {
|
||||
padding-top: 40px !important; /* Уменьшенный отступ сверху */
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
/* Positioning for axis titles to avoid overlap with main title */
|
||||
.chart-card__plot .xtitle,
|
||||
.chart-card__plot .ytitle {
|
||||
margin-top: 15px !important; /* Увеличенный отступ для подписей осей */
|
||||
}
|
||||
|
||||
.chart-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@ -165,9 +165,18 @@ export class WebSocketManager {
|
||||
this.emit('processor_history', payload);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('🔴 Server error:', payload.message);
|
||||
console.error('🔴 Server error:', payload);
|
||||
console.error('🔴 Error details:', {
|
||||
message: payload.message,
|
||||
code: payload.code,
|
||||
details: payload.details,
|
||||
timestamp: payload.timestamp,
|
||||
source: payload.source
|
||||
});
|
||||
this.notifications?.show?.({
|
||||
type: 'error', title: 'Server Error', message: payload.message
|
||||
type: 'error',
|
||||
title: 'Server Error',
|
||||
message: `${payload.message}${payload.details ? ` - ${payload.details}` : ''}${payload.source ? ` (${payload.source})` : ''}`
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user