some fixes and improvements
This commit is contained in:
@ -559,6 +559,84 @@ class BaseProcessor:
|
|||||||
if max_v is not None and value > max_v:
|
if max_v is not None and value > max_v:
|
||||||
raise ValueError(f"{key} {value} > max {max_v}")
|
raise ValueError(f"{key} {value} > max {max_v}")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# History Export/Import
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
def export_history_data(self) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Export sweep history in JSON-serializable format.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list[dict]
|
||||||
|
Serializable history records with sweep data converted to points.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
exported = []
|
||||||
|
for entry in self._sweep_history:
|
||||||
|
sweep_data = entry["sweep_data"]
|
||||||
|
calibrated_data = entry["calibrated_data"]
|
||||||
|
reference_data = entry.get("reference_data")
|
||||||
|
|
||||||
|
exported.append({
|
||||||
|
"sweep_number": sweep_data.sweep_number if sweep_data else None,
|
||||||
|
"timestamp": entry.get("timestamp"),
|
||||||
|
"sweep_points": sweep_data.points if sweep_data else [],
|
||||||
|
"calibrated_points": calibrated_data.points if calibrated_data else [],
|
||||||
|
"reference_points": reference_data.points if reference_data else [],
|
||||||
|
"vna_config": entry.get("vna_config", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return exported
|
||||||
|
|
||||||
|
def import_history_data(self, history_data: list[dict[str, Any]]) -> None:
|
||||||
|
"""
|
||||||
|
Import sweep history from JSON data.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
history_data : list[dict]
|
||||||
|
History records in the format exported by export_history_data.
|
||||||
|
"""
|
||||||
|
from vna_system.core.acquisition.sweep_buffer import SweepData
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._sweep_history.clear()
|
||||||
|
|
||||||
|
for entry in history_data:
|
||||||
|
sweep_points = entry.get("sweep_points", [])
|
||||||
|
calibrated_points = entry.get("calibrated_points", [])
|
||||||
|
reference_points = entry.get("reference_points", [])
|
||||||
|
|
||||||
|
# Reconstruct SweepData objects
|
||||||
|
sweep_data = SweepData(
|
||||||
|
sweep_number=entry.get("sweep_number", 0),
|
||||||
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
|
points=sweep_points
|
||||||
|
) if sweep_points else None
|
||||||
|
|
||||||
|
calibrated_data = SweepData(
|
||||||
|
sweep_number=entry.get("sweep_number", 0),
|
||||||
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
|
points=calibrated_points
|
||||||
|
) if calibrated_points else None
|
||||||
|
|
||||||
|
reference_data = SweepData(
|
||||||
|
sweep_number=entry.get("sweep_number", 0),
|
||||||
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
|
points=reference_points
|
||||||
|
) if reference_points else None
|
||||||
|
|
||||||
|
self._sweep_history.append({
|
||||||
|
"sweep_data": sweep_data,
|
||||||
|
"calibrated_data": calibrated_data,
|
||||||
|
"reference_data": reference_data,
|
||||||
|
"vna_config": entry.get("vna_config", {}),
|
||||||
|
"timestamp": entry.get("timestamp"),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("History imported", processor_id=self.processor_id, records=len(history_data))
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Utilities
|
# Utilities
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
@ -572,4 +650,5 @@ class BaseProcessor:
|
|||||||
"config": self._config.copy(),
|
"config": self._config.copy(),
|
||||||
"history_count": len(self._sweep_history),
|
"history_count": len(self._sweep_history),
|
||||||
"max_history": self._max_history,
|
"max_history": self._max_history,
|
||||||
|
"sweep_history": self.export_history_data(),
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -363,6 +364,8 @@ class BScanProcessor(BaseProcessor):
|
|||||||
self._plot_history.clear()
|
self._plot_history.clear()
|
||||||
|
|
||||||
# Process all sweeps in history with current config
|
# Process all sweeps in history with current config
|
||||||
|
last_processed = None
|
||||||
|
last_vna_config = {}
|
||||||
for entry in self._sweep_history:
|
for entry in self._sweep_history:
|
||||||
sweep_data = entry["sweep_data"]
|
sweep_data = entry["sweep_data"]
|
||||||
calibrated_data = entry["calibrated_data"]
|
calibrated_data = entry["calibrated_data"]
|
||||||
@ -372,8 +375,9 @@ class BScanProcessor(BaseProcessor):
|
|||||||
processed = self.process_sweep(sweep_data, calibrated_data, vna_config)
|
processed = self.process_sweep(sweep_data, calibrated_data, vna_config)
|
||||||
|
|
||||||
# Skip if processing failed
|
# Skip if processing failed
|
||||||
if "error" in processed:
|
if "error" not in processed:
|
||||||
continue
|
last_processed = processed
|
||||||
|
last_vna_config = vna_config
|
||||||
|
|
||||||
# Trim plot history if needed
|
# Trim plot history if needed
|
||||||
if len(self._plot_history) > self._max_history:
|
if len(self._plot_history) > self._max_history:
|
||||||
@ -383,17 +387,23 @@ class BScanProcessor(BaseProcessor):
|
|||||||
plot_records=len(self._plot_history),
|
plot_records=len(self._plot_history),
|
||||||
sweep_records=len(self._sweep_history))
|
sweep_records=len(self._sweep_history))
|
||||||
|
|
||||||
# Return the result based on the last sweep processed
|
# Build result from last successful processing
|
||||||
if self._sweep_history:
|
if last_processed is None:
|
||||||
latest = self._sweep_history[-1]
|
|
||||||
return self._process_data(
|
|
||||||
latest["sweep_data"],
|
|
||||||
latest["calibrated_data"],
|
|
||||||
latest["vna_config"]
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Generate plotly config and wrap into ProcessedResult
|
||||||
|
plotly_conf = self.generate_plotly_config(last_processed, last_vna_config)
|
||||||
|
ui_params = self.get_ui_parameters()
|
||||||
|
|
||||||
|
return ProcessedResult(
|
||||||
|
processor_id=self.processor_id,
|
||||||
|
timestamp=datetime.now().timestamp(),
|
||||||
|
data=last_processed,
|
||||||
|
plotly_config=plotly_conf,
|
||||||
|
ui_parameters=ui_params,
|
||||||
|
metadata=self._get_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Low-level helpers
|
# Low-level helpers
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
@ -71,25 +71,27 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
# Magnitude in dB (clamp zero magnitude to -120 dB)
|
# Magnitude in dB (clamp zero magnitude to -120 dB)
|
||||||
mags_db: list[float] = []
|
mags_db: list[float] = []
|
||||||
phases_deg: list[float] = []
|
phases_deg: list[float] = []
|
||||||
# real_points: list[str] = []
|
real_points: list[float] = []
|
||||||
# imag_points: list[str] = []
|
imag_points: list[float] = []
|
||||||
|
|
||||||
for real, imag in points:
|
for real, imag in points:
|
||||||
complex_val = complex(real, imag)
|
complex_val = complex(real, imag)
|
||||||
# real_points.append(str(real))
|
real_points.append(float(real))
|
||||||
# imag_points.append(str(imag))
|
imag_points.append(float(imag))
|
||||||
mag = abs(complex_val)
|
mag = abs(complex_val)
|
||||||
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
|
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
|
||||||
phases_deg.append(np.degrees(np.angle(complex_val)))
|
phases_deg.append(np.degrees(np.angle(complex_val)))
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"frequencies": freqs,
|
"frequencies": freqs,
|
||||||
# "real_points" : real_points,
|
"real_points": real_points,
|
||||||
# "imag_points" : imag_points,
|
"imag_points": imag_points,
|
||||||
"magnitudes_db": mags_db,
|
"magnitudes_db": mags_db,
|
||||||
"phases_deg": phases_deg,
|
"phases_deg": phases_deg,
|
||||||
"y_min": float(self._config.get("y_min", -80)),
|
"y_min": float(self._config.get("y_min", -80)),
|
||||||
"y_max": float(self._config.get("y_max", 10)),
|
"y_max": float(self._config.get("y_max", 10)),
|
||||||
|
"autoscale": bool(self._config.get("autoscale", False)),
|
||||||
|
"show_magnitude": bool(self._config.get("show_magnitude", True)),
|
||||||
"show_phase": bool(self._config.get("show_phase", False)),
|
"show_phase": bool(self._config.get("show_phase", False)),
|
||||||
}
|
}
|
||||||
logger.debug("Magnitude sweep processed", points=n)
|
logger.debug("Magnitude sweep processed", points=n)
|
||||||
@ -105,7 +107,9 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
freqs: list[float] = processed_data["frequencies"]
|
freqs: list[float] = processed_data["frequencies"]
|
||||||
mags_db: list[float] = processed_data["magnitudes_db"]
|
mags_db: list[float] = processed_data["magnitudes_db"]
|
||||||
phases_deg: list[float] = processed_data["phases_deg"]
|
phases_deg: list[float] = processed_data["phases_deg"]
|
||||||
|
show_magnitude: bool = processed_data["show_magnitude"]
|
||||||
show_phase: bool = processed_data["show_phase"]
|
show_phase: bool = processed_data["show_phase"]
|
||||||
|
autoscale: bool = processed_data["autoscale"]
|
||||||
|
|
||||||
# Determine the parameter type from preset mode
|
# Determine the parameter type from preset mode
|
||||||
parameter_type = vna_config["mode"].value.upper() # Convert "s11" -> "S11", "s21" -> "S21"
|
parameter_type = vna_config["mode"].value.upper() # Convert "s11" -> "S11", "s21" -> "S21"
|
||||||
@ -113,17 +117,23 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
# Convert Hz to GHz for x-axis
|
# Convert Hz to GHz for x-axis
|
||||||
freqs_ghz = [f / 1e9 for f in freqs]
|
freqs_ghz = [f / 1e9 for f in freqs]
|
||||||
|
|
||||||
traces = [
|
# Pleasant colors
|
||||||
{
|
magnitude_color = "rgb(46, 204, 113)" # Pleasant green
|
||||||
|
phase_color = "rgb(231, 76, 60)" # Pleasant red/coral
|
||||||
|
|
||||||
|
traces = []
|
||||||
|
|
||||||
|
# Add magnitude trace if enabled
|
||||||
|
if show_magnitude:
|
||||||
|
traces.append({
|
||||||
"x": freqs_ghz,
|
"x": freqs_ghz,
|
||||||
"y": mags_db,
|
"y": mags_db,
|
||||||
"type": "scatter",
|
"type": "scatter",
|
||||||
"mode": "lines",
|
"mode": "lines",
|
||||||
"name": f"|{parameter_type}| Magnitude",
|
"name": f"|{parameter_type}| Magnitude",
|
||||||
"line": {"color": "blue", "width": 2},
|
"line": {"color": magnitude_color, "width": 2},
|
||||||
"yaxis": "y",
|
"yaxis": "y",
|
||||||
}
|
})
|
||||||
]
|
|
||||||
|
|
||||||
# Add phase trace if enabled
|
# Add phase trace if enabled
|
||||||
if show_phase:
|
if show_phase:
|
||||||
@ -133,32 +143,56 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
"type": "scatter",
|
"type": "scatter",
|
||||||
"mode": "lines",
|
"mode": "lines",
|
||||||
"name": f"∠{parameter_type} Phase",
|
"name": f"∠{parameter_type} Phase",
|
||||||
"line": {"color": "red", "width": 2},
|
"line": {"color": phase_color, "width": 2},
|
||||||
"yaxis": "y2",
|
"yaxis": "y2" if show_magnitude else "y",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Layout configuration
|
# Layout configuration
|
||||||
layout = {
|
layout = {
|
||||||
"title": f"{parameter_type} Response",
|
"title": f"{parameter_type} Response",
|
||||||
"xaxis": {"title": "Frequency (GHz)", "showgrid": True},
|
"xaxis": {"title": "Frequency (GHz)", "showgrid": True},
|
||||||
"yaxis": {
|
|
||||||
"title": "Magnitude (dB)",
|
|
||||||
"range": [processed_data["y_min"], processed_data["y_max"]],
|
|
||||||
"showgrid": True,
|
|
||||||
"side": "left",
|
|
||||||
},
|
|
||||||
"hovermode": "x unified",
|
"hovermode": "x unified",
|
||||||
"showlegend": True,
|
"showlegend": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add second y-axis for phase if enabled
|
# Configure y-axis based on what's shown
|
||||||
|
if show_magnitude:
|
||||||
|
y_axis_config = {
|
||||||
|
"title": "Magnitude (dB)",
|
||||||
|
"showgrid": True,
|
||||||
|
"side": "left",
|
||||||
|
"titlefont": {"color": magnitude_color},
|
||||||
|
"tickfont": {"color": magnitude_color},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply autoscale or manual range
|
||||||
|
if not autoscale:
|
||||||
|
y_axis_config["range"] = [processed_data["y_min"], processed_data["y_max"]]
|
||||||
|
|
||||||
|
layout["yaxis"] = y_axis_config
|
||||||
|
|
||||||
|
# Add second y-axis for phase if both are shown
|
||||||
if show_phase:
|
if show_phase:
|
||||||
|
if show_magnitude:
|
||||||
|
# Phase on second axis (radians converted to degrees, but displayed as -π to π)
|
||||||
layout["yaxis2"] = {
|
layout["yaxis2"] = {
|
||||||
"title": "Phase (°)",
|
"title": "Phase (rad)",
|
||||||
"overlaying": "y",
|
"overlaying": "y",
|
||||||
"side": "right",
|
"side": "right",
|
||||||
"showgrid": False,
|
"showgrid": False,
|
||||||
"range": [-180, 180],
|
"range": [-180, 180], # -π to π in degrees
|
||||||
|
"titlefont": {"color": phase_color},
|
||||||
|
"tickfont": {"color": phase_color},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Phase on primary axis if magnitude is hidden
|
||||||
|
layout["yaxis"] = {
|
||||||
|
"title": "Phase (rad)",
|
||||||
|
"showgrid": True,
|
||||||
|
"side": "left",
|
||||||
|
"range": [-180, 180], # -π to π in degrees
|
||||||
|
"titlefont": {"color": phase_color},
|
||||||
|
"tickfont": {"color": phase_color},
|
||||||
}
|
}
|
||||||
|
|
||||||
fig = {
|
fig = {
|
||||||
@ -175,6 +209,24 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
UI/validation schema for magnitude processor.
|
UI/validation schema for magnitude processor.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
|
UIParameter(
|
||||||
|
name="show_magnitude",
|
||||||
|
label="Show Magnitude",
|
||||||
|
type="toggle",
|
||||||
|
value=self._config.get("show_magnitude", True),
|
||||||
|
),
|
||||||
|
UIParameter(
|
||||||
|
name="show_phase",
|
||||||
|
label="Show Phase",
|
||||||
|
type="toggle",
|
||||||
|
value=self._config.get("show_phase", False),
|
||||||
|
),
|
||||||
|
UIParameter(
|
||||||
|
name="autoscale",
|
||||||
|
label="Autoscale Y Axis",
|
||||||
|
type="toggle",
|
||||||
|
value=self._config.get("autoscale", False),
|
||||||
|
),
|
||||||
UIParameter(
|
UIParameter(
|
||||||
name="y_min",
|
name="y_min",
|
||||||
label="Y Axis Min (dB)",
|
label="Y Axis Min (dB)",
|
||||||
@ -189,12 +241,6 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
value=self._config.get("y_max", 10),
|
value=self._config.get("y_max", 10),
|
||||||
options={"min": -20, "max": 40, "step": 5, "dtype": "int"},
|
options={"min": -20, "max": 40, "step": 5, "dtype": "int"},
|
||||||
),
|
),
|
||||||
UIParameter(
|
|
||||||
name="show_phase",
|
|
||||||
label="Show Phase",
|
|
||||||
type="toggle",
|
|
||||||
value=self._config.get("show_phase", False),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _get_default_config(self) -> dict[str, Any]:
|
def _get_default_config(self) -> dict[str, Any]:
|
||||||
@ -202,6 +248,8 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
return {
|
return {
|
||||||
"y_min": -80,
|
"y_min": -80,
|
||||||
"y_max": 10,
|
"y_max": 10,
|
||||||
|
"autoscale": False,
|
||||||
|
"show_magnitude": True,
|
||||||
"show_phase": False,
|
"show_phase": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -156,6 +156,46 @@ class ProcessorManager:
|
|||||||
logger.error("Recalculation error", processor_id=processor_id, error=repr(exc))
|
logger.error("Recalculation error", processor_id=processor_id, error=repr(exc))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def load_processor_history(self, processor_id: str, history_data: list[dict[str, Any]]) -> ProcessedResult | None:
|
||||||
|
"""
|
||||||
|
Load sweep history into a processor from JSON data and recalculate.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
processor_id : str
|
||||||
|
The processor to load history into.
|
||||||
|
history_data : list[dict]
|
||||||
|
History records in the format exported by export_history_data.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ProcessedResult | None
|
||||||
|
The result of recalculation after loading history.
|
||||||
|
"""
|
||||||
|
processor = self.get_processor(processor_id)
|
||||||
|
if not processor:
|
||||||
|
raise ValueError(f"Processor {processor_id} not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
processor.import_history_data(history_data)
|
||||||
|
result = processor.recalculate()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
with self._lock:
|
||||||
|
callbacks = list(self._result_callbacks)
|
||||||
|
for cb in callbacks:
|
||||||
|
try:
|
||||||
|
cb(processor_id, result)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Result callback failed", processor_id=processor_id, error=repr(exc))
|
||||||
|
|
||||||
|
logger.info("History loaded and recalculated", processor_id=processor_id, records=len(history_data))
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("History load error", processor_id=processor_id, error=repr(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Runtime control
|
# Runtime control
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|||||||
@ -100,6 +100,8 @@ class ProcessorWebSocketHandler:
|
|||||||
await self._handle_recalculate(websocket, message)
|
await self._handle_recalculate(websocket, message)
|
||||||
elif mtype == "get_history":
|
elif mtype == "get_history":
|
||||||
await self._handle_get_history(websocket, message)
|
await self._handle_get_history(websocket, message)
|
||||||
|
elif mtype == "load_history":
|
||||||
|
await self._handle_load_history(websocket, message)
|
||||||
else:
|
else:
|
||||||
await self._send_error(websocket, f"Unknown message type: {mtype!r}")
|
await self._send_error(websocket, f"Unknown message type: {mtype!r}")
|
||||||
except json.JSONDecodeError as json_error:
|
except json.JSONDecodeError as json_error:
|
||||||
@ -170,6 +172,31 @@ class ProcessorWebSocketHandler:
|
|||||||
logger.error("Error getting history")
|
logger.error("Error getting history")
|
||||||
await self._send_error(websocket, f"Error getting history: {exc}")
|
await self._send_error(websocket, f"Error getting history: {exc}")
|
||||||
|
|
||||||
|
async def _handle_load_history(self, websocket: WebSocket, message: dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Load sweep history from JSON data into a processor and recalculate.
|
||||||
|
"""
|
||||||
|
processor_id = message.get("processor_id")
|
||||||
|
history_data = message.get("history_data")
|
||||||
|
|
||||||
|
if not processor_id:
|
||||||
|
await self._send_error(websocket, "processor_id is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not history_data or not isinstance(history_data, list):
|
||||||
|
await self._send_error(websocket, "history_data (list) is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.processor_manager.load_processor_history(processor_id, history_data)
|
||||||
|
if result:
|
||||||
|
await websocket.send_text(json.dumps(self._result_to_message(processor_id, result)))
|
||||||
|
else:
|
||||||
|
await self._send_error(websocket, f"No result from processor {processor_id} after loading history")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("History load failed", processor_id=processor_id, error=repr(exc))
|
||||||
|
await self._send_error(websocket, f"History load failed: {exc}")
|
||||||
|
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Outbound helpers
|
# Outbound helpers
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
|||||||
@ -107,13 +107,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header__status {
|
.header__status {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.header__stats {
|
.header__stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@ -387,9 +392,21 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.header__controls .btn__text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__controls .btn {
|
||||||
|
min-width: auto;
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header__container {
|
.header__container {
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header__brand {
|
.header__brand {
|
||||||
@ -402,6 +419,13 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header__controls {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -319,6 +319,9 @@ export class ChartManager {
|
|||||||
|
|
||||||
if (!chart || !latestData) return null;
|
if (!chart || !latestData) return null;
|
||||||
|
|
||||||
|
// Extract sweep_history from metadata if available
|
||||||
|
const sweepHistory = latestData.metadata?.sweep_history || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processor_info: {
|
processor_info: {
|
||||||
processor_id: processorId,
|
processor_id: processorId,
|
||||||
@ -330,7 +333,8 @@ export class ChartManager {
|
|||||||
metadata: safeClone(latestData.metadata),
|
metadata: safeClone(latestData.metadata),
|
||||||
timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp,
|
timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp,
|
||||||
plotly_config: safeClone(latestData.plotly_config)
|
plotly_config: safeClone(latestData.plotly_config)
|
||||||
}
|
},
|
||||||
|
sweep_history: sweepHistory
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,11 +63,22 @@ export class ChartSettingsManager {
|
|||||||
this.updateParametersSelectively(processorId, settingsContainer, uiParameters);
|
this.updateParametersSelectively(processorId, settingsContainer, uiParameters);
|
||||||
} else {
|
} else {
|
||||||
// Initial render: full rebuild
|
// Initial render: full rebuild
|
||||||
const settingsHtml = uiParameters.map(param =>
|
const loadHistoryButton = `
|
||||||
|
<div class="chart-setting" style="border-top: 1px solid var(--border-primary); padding-top: var(--space-3); margin-bottom: var(--space-3);">
|
||||||
|
<button class="btn btn--secondary btn--sm" id="loadHistoryBtn_${processorId}" style="width: 100%;">
|
||||||
|
<span data-icon="upload"></span>
|
||||||
|
<span>Загрузить историю</span>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="historyFileInput_${processorId}" accept=".json" style="display: none;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const settingsHtml = loadHistoryButton + uiParameters.map(param =>
|
||||||
createParameterControl(param, processorId, 'chart')
|
createParameterControl(param, processorId, 'chart')
|
||||||
).join('');
|
).join('');
|
||||||
settingsContainer.innerHTML = settingsHtml;
|
settingsContainer.innerHTML = settingsHtml;
|
||||||
this.setupEvents(settingsContainer, processorId);
|
this.setupEvents(settingsContainer, processorId);
|
||||||
|
this.setupLoadHistoryButton(processorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize last values
|
// Initialize last values
|
||||||
@ -282,6 +293,72 @@ export class ChartSettingsManager {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupLoadHistoryButton(processorId) {
|
||||||
|
const loadBtn = document.getElementById(`loadHistoryBtn_${processorId}`);
|
||||||
|
const fileInput = document.getElementById(`historyFileInput_${processorId}`);
|
||||||
|
|
||||||
|
if (!loadBtn || !fileInput) return;
|
||||||
|
|
||||||
|
loadBtn.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const jsonData = JSON.parse(text);
|
||||||
|
|
||||||
|
// Extract sweep_history from the saved JSON file
|
||||||
|
const sweepHistory = jsonData.sweep_history || [];
|
||||||
|
|
||||||
|
if (!sweepHistory || sweepHistory.length === 0) {
|
||||||
|
window.vnaDashboard?.notifications?.show?.({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Ошибка загрузки',
|
||||||
|
message: 'Файл не содержит истории свипов'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send load_history message via WebSocket
|
||||||
|
const websocket = window.vnaDashboard?.websocket;
|
||||||
|
if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) {
|
||||||
|
websocket.ws.send(JSON.stringify({
|
||||||
|
type: 'load_history',
|
||||||
|
processor_id: processorId,
|
||||||
|
history_data: sweepHistory
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.vnaDashboard?.notifications?.show?.({
|
||||||
|
type: 'success',
|
||||||
|
title: 'История загружена',
|
||||||
|
message: `Загружено ${sweepHistory.length} записей для ${processorId}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.vnaDashboard?.notifications?.show?.({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Ошибка подключения',
|
||||||
|
message: 'WebSocket не подключен'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading history:', err);
|
||||||
|
window.vnaDashboard?.notifications?.show?.({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Ошибка загрузки',
|
||||||
|
message: `Не удалось прочитать файл: ${err.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// Clear timers
|
// Clear timers
|
||||||
Object.keys(this.settingDebounceTimers).forEach(key => {
|
Object.keys(this.settingDebounceTimers).forEach(key => {
|
||||||
|
|||||||
@ -42,6 +42,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="header__controls">
|
||||||
|
<button class="btn btn--primary btn--sm" id="startBtn" title="Запустить непрерывный сбор">
|
||||||
|
<span data-icon="play"></span>
|
||||||
|
<span class="btn__text">Запуск</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--secondary btn--sm" id="stopBtn" title="Остановить сбор">
|
||||||
|
<span data-icon="square"></span>
|
||||||
|
<span class="btn__text">Стоп</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--accent btn--sm" id="singleSweepBtn" title="Запустить одиночный свип">
|
||||||
|
<span data-icon="zap"></span>
|
||||||
|
<span class="btn__text">Одиночный</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="header__nav">
|
<nav class="header__nav">
|
||||||
<button class="nav-btn nav-btn--active" data-view="dashboard">
|
<button class="nav-btn nav-btn--active" data-view="dashboard">
|
||||||
<span data-icon="bar-chart-3"></span>
|
<span data-icon="bar-chart-3"></span>
|
||||||
@ -63,22 +78,8 @@
|
|||||||
<div class="controls-panel">
|
<div class="controls-panel">
|
||||||
<div class="controls-panel__container">
|
<div class="controls-panel__container">
|
||||||
<div class="controls-group">
|
<div class="controls-group">
|
||||||
<label class="controls-label">Управление сбором</label>
|
<label class="controls-label">Информация о сборе</label>
|
||||||
<div class="acquisition-controls">
|
<div class="acquisition-controls">
|
||||||
<div class="acquisition-controls__buttons">
|
|
||||||
<button class="btn btn--primary" id="startBtn" title="Запустить непрерывный сбор">
|
|
||||||
<span data-icon="play"></span>
|
|
||||||
Запуск
|
|
||||||
</button>
|
|
||||||
<button class="btn btn--secondary" id="stopBtn" title="Остановить сбор">
|
|
||||||
<span data-icon="square"></span>
|
|
||||||
Стоп
|
|
||||||
</button>
|
|
||||||
<button class="btn btn--accent" id="singleSweepBtn" title="Запустить одиночный свип">
|
|
||||||
<span data-icon="zap"></span>
|
|
||||||
Одиночный
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="acquisition-summary header__summary" id="headerSummary">
|
<div class="acquisition-summary header__summary" id="headerSummary">
|
||||||
<div class="header-summary__item">
|
<div class="header-summary__item">
|
||||||
<span class="header-summary__label">Пресет</span>
|
<span class="header-summary__label">Пресет</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user