Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+544
View File
@@ -0,0 +1,544 @@
#!/usr/bin/env python3
"""Фазовая калибровка MIMO-радара по точечному отражателю (шару) и применение её к данным.
Расчёт тот же, что в MIMO_calibration.ipynb, без графиков и диагностики. Формат файлов
калибровки (cal_*.npy, cal_meta.json) и папки postprocessed — тот же.
Запуск целиком: python mimo_calibration.py (работает по параметрам из блока ниже)
Из другого кода:
import mimo_calibration as mc
cal = mc.compute_calibration(cal_sets, empty_scene_path, mc.Settings(...))
mc.save_calibration(cal, cal_dir)
cal = mc.load_calibration(cal_dir)
s21_corr = mc.apply_calibration(s21, freq, cal) # массив (TX, RX, Nf) в памяти
mc.apply_to_measurement(meas_dir, cal) # preprocessed -> postprocessed
"""
import datetime
import json
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from scipy.optimize import minimize
# ╔══════════════════════════════════════════════════════════════════════════════════════╗
# ║ ЗАДАВАЕМЫЕ ПАРАМЕТРЫ ║
# ╚══════════════════════════════════════════════════════════════════════════════════════╝
# --- что делать -------------------------------------------------------------------------
RUN_CALIBRATION = True # True -> посчитать калибровку по CAL_SETS и записать в CAL_DIR;
# False -> взять готовую калибровку из CAL_DIR
APPLY_TO_DATA = False # True -> записать исправленные данные в <измерение>/postprocessed
# --- калибровочные измерения ------------------------------------------------------------
# Каждая запись — одно положение отражателя. snapshots: список префиксов снимков ('0003')
# или None (все снимки папки). Несколько снимков одной записи усредняются.
# target_scene_m = (X, Y, Z) в осях сцены, метры; для шара — координаты его ЦЕНТРА,
# измеренные физически (а не взятые с некалиброванного изображения):
# X — поперёк, вправо, от начала координат антенн (config_profile, x = 0);
# Y — высота центра отражателя над землёй;
# Z — расстояние вдоль земли от точки под началом координат антенн до отражателя.
CAL_ROOT = Path('/Users/ivan_root/Downloads/Telegram_dwnld/2026-09-31 balls calib/'
'librevna_multi_0.5-6GHz_1031pts_-3dBm_big_ball_h_0cm_z_5m_x_0cm/preprocessed')
CAL_SETS = [
dict(path=CAL_ROOT, snapshots=['0000'], target_scene_m=(0.0, 0.16, 5.20)), # Здесь нужно указывать координаты, измернные
dict(path=CAL_ROOT, snapshots=['0001'], target_scene_m=(0.0, 0.16, 5.20)), # в системе координат земли (x,y,z)
dict(path=CAL_ROOT, snapshots=['0002'], target_scene_m=(0.0, 0.16, 5.20)), # X - поеперчная координата от центра радара
dict(path=CAL_ROOT, snapshots=['0003'], target_scene_m=(0.0, 0.16, 5.20)), # y - выота от поверхности земли
dict(path=CAL_ROOT, snapshots=['0004'], target_scene_m=(0.0, 0.16, 5.20)), # z - длина вдоль земли от вертикальной плоскости, про
] # проходящей через нижние антенны до центра шара (до переднего края + радиус)
SPHERE_RADIUS_M = 0.16 # радиус шара-отражателя [м]; 0 -> точечная цель
EMPTY_SCENE_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/2026-09-31 balls calib/'
'librevna_multi_0.5-6GHz_1031pts_-3dBm_bkgd_h_0cm/preprocessed')
# папка снимков той же сцены БЕЗ отражателя (вычитается их среднее);
# None -> без вычитания
# --- геометрия (как в софте построения изображений) ---------------------------------------
INPUT_IDX = [0, 1, 2, 3] # физические входы (Rx), input_pos
OUTPUT_IDX = [0, 1, 2, 3] # физические выходы (Tx), output_pos
ANTENNA_HEIGHT_M = 0.92 # высота начала координат антенн (нижний ряд) над землёй [м]
PITCH_DEG = 10.0 # наклон оси радара ниже горизонта [град]
# --- оценка -----------------------------------------------------------------------------
CAL_F_MIN = 2.0e9 # полоса, по которой оценивается калибровка [Гц]
CAL_F_MAX = 6.0e9
GATE_HALF_M = 0.15 # окно вокруг эха цели, ± по бистатическому пути [м]
GATE_TAPER_M = 0.03 # плавные края окна [м]
COARSE_RANGE_SEARCH_M = 0.6 # грубый поиск эха по дальности перед точной подгонкой, ± по пути [м];
# 0 -> выключен
REFINE_AXES = ('x', 'y', 'z') # какие координаты цели уточнять по данным; если X и Y измерены
# точно — ('z',)
REFINE_RANGE_M = 0.10 # уточнение не дальше этого от найденной точки [м]
APPLY_AMPLITUDE = False # True -> поправка выравнивает и амплитуды антенн
PHASE_MODEL = 'linear' # модель фазы антенны по частоте: 'const' | 'linear' | 'poly3' | 'raw'
# --- куда писать калибровку ---------------------------------------------------------------
CAL_NAME = 'cal_20260931_big_ball_h0_script'
CAL_DIR = Path(__file__).resolve().parent / 'results' / 'calibration' / CAL_NAME
OVERWRITE = True # True -> перезаписать файлы калибровки в CAL_DIR
# --- к каким измерениям применять ---------------------------------------------------------
# Список папок измерений (тех, где лежат preprocessed, raw, results).
APPLY_TO_MEASUREMENTS = [
# '/путь/к/измерению',
]
OVERWRITE_POSTPROCESSED = False # True -> пересоздать postprocessed (только созданный этой калибровкой:
# папка без calibration_applied.json не удаляется никогда)
# ╔══════════════════════════════════════════════════════════════════════════════════════╗
# ║ КОНЕЦ ЗАДАВАЕМЫХ ПАРАМЕТРОВ ║
# ╚══════════════════════════════════════════════════════════════════════════════════════╝
C_LIGHT = 299_792_458.0
@dataclass
class Settings:
"""Настройки расчёта; значения по умолчанию — из блока параметров выше."""
sphere_radius_m: float = SPHERE_RADIUS_M
input_idx: tuple = tuple(INPUT_IDX)
output_idx: tuple = tuple(OUTPUT_IDX)
antenna_height_m: float = ANTENNA_HEIGHT_M
pitch_deg: float = PITCH_DEG
f_min: float = CAL_F_MIN
f_max: float = CAL_F_MAX
gate_half_m: float = GATE_HALF_M
gate_taper_m: float = GATE_TAPER_M
coarse_range_search_m: float = COARSE_RANGE_SEARCH_M
refine_axes: tuple = tuple(REFINE_AXES)
refine_range_m: float = REFINE_RANGE_M
apply_amplitude: bool = APPLY_AMPLITUDE
phase_model: str = PHASE_MODEL
# ══════════════════════════════════════════════════════
# ЗАГРУЗКА
# ══════════════════════════════════════════════════════
def find_config_profile(start, max_levels=8):
p = Path(start)
for _ in range(max_levels):
if (p / 'config_profile.json').exists():
return p / 'config_profile.json'
p = p.parent
raise FileNotFoundError(f'config_profile.json не найден выше {start}')
def load_geometry(config_path, input_idx, output_idx):
"""Координаты антенн в осях радара [м], массивы (N,3) в порядке сортировки физических портов."""
gpr = json.load(open(config_path, encoding='utf-8'))['gpr']
tx = {int(e['output_pos']): (e['x_m'], e.get('y_m', 0.0), e.get('z_m', 0.0)) for e in gpr['tx_geometry']}
rx = {int(e['input_pos']): (e['x_m'], e.get('y_m', 0.0), e.get('z_m', 0.0)) for e in gpr['rx_geometry']}
return (np.array([tx[k] for k in sorted(output_idx)], float),
np.array([rx[k] for k in sorted(input_idx)], float))
def list_snapshots(root, prefixes=None):
dirs = sorted(p for p in Path(root).iterdir() if p.is_dir() and (p / 'meta.json').exists())
if prefixes is None:
return dirs
out = [d for d in dirs if any(d.name.startswith(str(pr)) for pr in prefixes)]
if len(out) != len(prefixes):
raise FileNotFoundError(f'В {root} найдены не все снимки {prefixes}')
return out
def load_s21(snapshot_dir, input_idx, output_idx):
"""S21 (TX, RX, Nf) и частоты. Индексы — порядок сортировки физических портов."""
s21, freq = None, None
for a, o in enumerate(sorted(output_idx)):
for b, i in enumerate(sorted(input_idx)):
s = np.load(Path(snapshot_dir) / f'i{i}_o{o}_s21.npy')
f = np.load(Path(snapshot_dir) / f'i{i}_o{o}_freq.npy')
if s21 is None:
s21 = np.zeros((len(output_idx), len(input_idx), s.size), complex); freq = f
elif not np.allclose(f, freq):
raise ValueError(f'Разные частотные сетки у пар в {snapshot_dir}')
s21[a, b] = s
return s21, freq
# ══════════════════════════════════════════════════════
# РАСЧЁТ КАЛИБРОВКИ
# ══════════════════════════════════════════════════════
def best_phases(Z):
"""max |Σ Z_ij·e^{-j(t_i+r_j)}| по фазам антенн, t_0 = r_0 = 0."""
t = np.zeros(Z.shape[0]); r = np.zeros(Z.shape[1])
for _ in range(100):
t = np.angle((Z * np.exp(-1j * r)[None, :]).sum(axis=1))
r = np.angle((Z * np.exp(-1j * t)[:, None]).sum(axis=0))
t = t - t[0]; r = r - r[0]
return t, r
def coherence(Z, t=None, r=None):
"""Сфазированность вкладов пар: |Σ| / Σ|·| (1 — все в фазе)."""
if t is not None:
Z = Z * np.exp(-1j * (t[:, None] + r[None, :]))
return float(np.abs(Z.sum()) / (np.abs(Z).sum() + 1e-30))
def circular_median(phases):
"""Круговая медиана по первой оси: значение с минимальной суммой угловых расстояний до остальных."""
d = np.abs(np.angle(np.exp(1j * (phases[:, None] - phases[None, :]))))
best = np.argmin(d.sum(axis=1), axis=0)
return np.take_along_axis(phases, best[None], axis=0)[0]
def combine(sets):
"""Добавки антенн по всем калибровочным точкам: круговая медиана фаз и медиана усиления."""
out = []
for key in ('T', 'R'):
Z = np.array([s[key] for s in sets]) # (точки, антенны, частоты)
out.append(np.exp(np.median(np.log(np.abs(Z)), axis=0)) * np.exp(1j * circular_median(np.angle(Z))))
return tuple(out)
class _Estimator:
"""Геометрия, полоса и окно одной калибровки."""
def __init__(self, st, tx, rx, freq):
self.st, self.tx, self.rx, self.freq = st, tx, rx, freq
self.n_tx, self.n_rx = len(tx), len(rx)
self.band = (freq >= st.f_min) & (freq <= st.f_max)
self.fb = freq[self.band]
self.win = np.hanning(self.fb.size)
self.ok = self.win > 0.2 # края полосы после деления на окно ненадёжны
self.nfft = 1 << int(np.ceil(np.log2(self.fb.size * 8)))
self.dpath = np.fft.fftfreq(self.nfft, self.fb[1] - self.fb[0]) * C_LIGHT # ось «лишний путь», м
self.gate = np.clip(np.minimum((self.dpath + st.gate_half_m) / st.gate_taper_m,
(st.gate_half_m - self.dpath) / st.gate_taper_m) + 0.5, 0, 1)
def scene_to_radar(self, q):
"""(X, Y над землёй, Z вдоль земли) -> оси радара (x, y, z)."""
p = np.radians(self.st.pitch_deg); y0 = q[1] - self.st.antenna_height_m
return np.array([q[0], y0 * np.cos(p) + q[2] * np.sin(p), -y0 * np.sin(p) + q[2] * np.cos(p)])
def pair_paths(self, q_scene):
"""Бистатический путь каждой пары до цели (TX, RX). Для шара радиуса a (q_scene — центр)
путь короче на 2a·cos(beta/2), beta — угол между направлениями из центра на Tx и на Rx."""
a = self.st.sphere_radius_m
pr = self.scene_to_radar(np.asarray(q_scene, float))
vt = self.tx - pr; vr = self.rx - pr
dt = np.linalg.norm(vt, axis=1); dr = np.linalg.norm(vr, axis=1)
paths = dt[:, None] + dr[None, :]
if a:
cos_beta = (vt @ vr.T) / (dt[:, None] * dr[None, :])
paths = paths - 2 * a * np.sqrt((1 + cos_beta) / 2)
return paths
def _unwound(self, s21_band, paths):
return s21_band * np.exp(2j * np.pi * self.fb[None, None, :] * paths[:, :, None] / C_LIGHT)
def target_echo(self, s21_band, paths):
"""Эхо цели E (TX, RX, Nb) со снятым путём: окно ±gate_half_m вокруг заданной точки."""
h = np.fft.ifft(self._unwound(s21_band, paths) * self.win, self.nfft, axis=2)
return np.fft.fft(h * self.gate, axis=2)[:, :, :self.fb.size] / np.maximum(self.win, 1e-3)
def broadband(self, E):
"""Вклад пары в изображение в точке: сумма эха по надёжной части полосы."""
return E[:, :, self.ok].sum(axis=2)
def coarse_range(self, s21, q_nominal):
"""Грубая привязка по дальности: максимум суммарной огибающей всех пар в пределах
±coarse_range_search_m по пути; заданная дальность Z сдвигается под него."""
q = q_nominal.copy()
if not self.st.coarse_range_search_m:
return q
s = np.mean(s21, axis=0)[:, :, self.band]; sel = np.abs(self.dpath) <= self.st.coarse_range_search_m
for _ in range(3):
env = np.abs(np.fft.ifft(self._unwound(s, self.pair_paths(q)) * self.win, self.nfft, axis=2)).sum(axis=(0, 1))
q[2] += self.dpath[sel][np.argmax(env[sel])] / 2
return q
def refine_target(self, s21, q0):
"""Положение цели и широкополосные фазы антенн, максимизирующие сфазированность."""
P0 = self.pair_paths(q0)
E0 = np.mean([self.target_echo(s[:, :, self.band], P0) for s in s21], axis=0)
axes = [('xyz').index(a) for a in self.st.refine_axes]
def E_at(v):
q = q0.copy(); q[axes] += v
dP = self.pair_paths(q) - P0
return E0 * np.exp(2j * np.pi * self.fb[None, None, :] * dP[:, :, None] / C_LIGHT), q
def score(v, t, r):
if np.any(np.abs(v) > self.st.refine_range_m):
return 0.0
return coherence(self.broadband(E_at(v)[0]), t, r)
v = np.zeros(len(axes))
if axes:
for _ in range(6):
t, r = best_phases(self.broadband(E_at(v)[0]))
simplex = np.vstack([v] + [v + 0.03 * np.eye(len(axes))[k] for k in range(len(axes))])
v = minimize(lambda w: -score(w, t, r), v, method='Nelder-Mead',
options=dict(xatol=5e-4, fatol=1e-7, initial_simplex=simplex)).x
return E_at(v)
def factorize(self, E):
"""E_ij(f) ≈ g(f)·T_i(f)·R_j(f) на каждой частоте: фазы — best_phases, амплитуды — МНК по log|E|."""
nb = E.shape[2]
T = np.ones((self.n_tx, nb), complex); R = np.ones((self.n_rx, nb), complex)
L = np.log(np.abs(E) + 1e-30)
for k in range(nb):
t, r = best_phases(E[:, :, k])
tt = L[:, :, k].mean(axis=1) - L[:, :, k].mean(); rr = L[:, :, k].mean(axis=0) - L[:, :, k].mean()
T[:, k] = np.exp(tt + 1j * t); R[:, k] = np.exp(rr + 1j * r)
return T, R
def antenna_params(self, Z):
"""Фаза, продолженная к 0 Гц, задержка (эквивалентный лишний путь) и усиление антенны."""
ph = np.unwrap(np.angle(Z[self.ok])); f = self.fb[self.ok]
slope, ph0 = np.polyfit(f, ph, 1)
return dict(phase0_deg=float(np.degrees(ph0)), delay_path_m=float(-slope * C_LIGHT / (2 * np.pi)),
gain_db=float(20 * np.log10(np.mean(np.abs(Z[self.ok])))),
phase_rms_after_linear_deg=float(np.degrees(np.std(ph - (slope * f + ph0)))))
def phase_model(self, Z):
"""Фаза добавки антенны по модели phase_model (подгонка по надёжной части полосы)."""
model = self.st.phase_model
if model == 'raw':
return Z
ph = np.unwrap(np.angle(Z)); f = (self.fb - self.fb.mean()) / 1e9
if model == 'const':
u = np.exp(1j * np.angle(Z[self.ok])).mean()
return np.abs(Z) * np.exp(1j * np.angle(u))
deg = {'linear': 1, 'poly3': 3}.get(model)
if deg is None:
raise ValueError(f"phase_model должен быть 'const', 'linear', 'poly3' или 'raw', а не {model!r}")
return np.abs(Z) * np.exp(1j * np.polyval(np.polyfit(f[self.ok], ph[self.ok], deg), f))
def pair_correction(self, T, R):
"""Поправка пары C_ij(f) = 1/(T_i·R_j); без apply_amplitude — только фаза."""
Z = T[:, None, :] * R[None, :, :]
return np.conj(Z) / np.abs(Z) if not self.st.apply_amplitude else 1.0 / Z
def to_full_grid(self, Z_band, params):
"""На полную сетку частот: внутри надёжной полосы — оценка, вне её — продолжение с наклоном
линейной модели фазы антенны, пристыкованное к краю полосы без скачка."""
idx = np.flatnonzero(self.band)[self.ok]
ph_in = np.unwrap(np.angle(Z_band[self.ok])); mag_in = np.abs(Z_band[self.ok])
slope = -2 * np.pi * params['delay_path_m'] / C_LIGHT # рад/Гц
ph = np.empty(self.freq.size); mag = np.empty(self.freq.size)
ph[idx] = ph_in; mag[idx] = mag_in
lo, hi = idx[0], idx[-1]
ph[:lo] = ph_in[0] + slope * (self.freq[:lo] - self.freq[lo]); mag[:lo] = mag_in[0]
ph[hi + 1:] = ph_in[-1] + slope * (self.freq[hi + 1:] - self.freq[hi]); mag[hi + 1:] = mag_in[-1]
return mag * np.exp(1j * ph)
def compute_calibration(cal_sets, empty_scene_path=None, settings=None):
"""Калибровка по набору измерений отражателя. Возвращает словарь в том же виде, что load_calibration."""
st = settings or Settings()
cal_sets = list(cal_sets)
config_path = find_config_profile(cal_sets[0]['path'])
tx, rx = load_geometry(config_path, st.input_idx, st.output_idx)
freq = None
empty = None
if empty_scene_path is not None:
loaded = [load_s21(d, st.input_idx, st.output_idx) for d in list_snapshots(empty_scene_path)]
empty = np.mean([s for s, _ in loaded], axis=0); freq = loaded[0][1]
sets = []
for n, cs in enumerate(cal_sets):
snaps = list_snapshots(cs['path'], cs.get('snapshots'))
data = []
for d in snaps:
s21, f = load_s21(d, st.input_idx, st.output_idx)
if freq is None:
freq = f
elif f.shape != freq.shape or not np.allclose(f, freq):
raise ValueError(f'Частотная сетка в {d} отличается от остальных калибровочных данных')
cfg = find_config_profile(d)
if cfg != config_path:
tx_d, rx_d = load_geometry(cfg, st.input_idx, st.output_idx)
if not (np.allclose(tx_d, tx) and np.allclose(rx_d, rx)):
raise ValueError(f'Геометрия в {d} отличается от {config_path}')
data.append(s21 - empty if empty is not None else s21)
sets.append(dict(n=n, snaps=[d.name for d in snaps], s21=np.array(data),
q_nominal=np.array(cs['target_scene_m'], float)))
est = _Estimator(st, tx, rx, freq)
for s in sets:
s['E'], s['q'] = est.refine_target(s['s21'], est.coarse_range(s['s21'], s['q_nominal']))
s['T'], s['R'] = est.factorize(s['E'])
T_all, R_all = combine(sets)
T_all = np.array([est.phase_model(z) for z in T_all]); R_all = np.array([est.phase_model(z) for z in R_all])
ant = {f'Tx{k}': est.antenna_params(T_all[k]) for k in range(est.n_tx)}
ant.update({f'Rx{k}': est.antenna_params(R_all[k]) for k in range(est.n_rx)})
# качество: сфазированность пар в точке цели без поправки и с ней
coh_with = lambda s, C: coherence(est.broadband(s['E'] * C))
quality = dict(coherence_before=[], coherence_own=[], coherence_leave_one_out=[], coherence_final=[])
for s in sets:
others = [o for o in sets if o is not s]
quality['coherence_before'].append(coh_with(s, 1.0))
quality['coherence_own'].append(coh_with(s, est.pair_correction(s['T'], s['R'])))
quality['coherence_leave_one_out'].append(coh_with(s, est.pair_correction(*combine(others))) if others else float('nan'))
quality['coherence_final'].append(coh_with(s, est.pair_correction(T_all, R_all)))
T_full = np.array([est.to_full_grid(T_all[k], ant[f'Tx{k}']) for k in range(est.n_tx)])
R_full = np.array([est.to_full_grid(R_all[k], ant[f'Rx{k}']) for k in range(est.n_rx)])
Z_full = T_full[:, None, :] * R_full[None, :, :]
unit = (lambda Z: np.conj(Z) / np.abs(Z)) if not st.apply_amplitude else (lambda Z: 1.0 / Z)
valid = np.zeros(freq.size, bool); valid[np.flatnonzero(est.band)[est.ok]] = True
meta = dict(
created=datetime.datetime.now().isoformat(timespec='seconds'), name=None,
convention=dict(apply='S21_corrected[tx, rx, f] = S21[tx, rx, f] * cal_pair[tx, rx, f]',
index_order='tx = номер в cal_tx_output_pos (output_pos по возрастанию), rx = cal_rx_input_pos',
files='S21 пары в данных: i{input_pos}_o{output_pos}_s21.npy',
phase_reference='фазы относительно Tx0 и Rx0; общая фаза/задержка не входит',
amplitude='выравнивается' if st.apply_amplitude else 'не меняется (только фаза)',
outside_band='вне cal_valid: продолжение линейной моделью фазы антенны'),
geometry=dict(config=str(config_path), tx_xyz_m=tx.tolist(), rx_xyz_m=rx.tolist(),
antenna_height_m=st.antenna_height_m, pitch_deg=st.pitch_deg),
estimation=dict(band_hz=[st.f_min, st.f_max], gate_half_m=st.gate_half_m, refine_axes=list(st.refine_axes),
phase_model=st.phase_model, sphere_radius_m=st.sphere_radius_m, empty_scene=str(empty_scene_path)),
targets=[dict(path=str(cal_sets[s['n']]['path']), snapshots=s['snaps'], nominal_scene_m=s['q_nominal'].tolist(),
refined_scene_m=s['q'].tolist()) for s in sets],
antennas=ant, quality=quality)
return dict(
freq_hz=freq.astype(np.float64),
pair=unit(Z_full).astype(np.complex64), # (TX, RX, Nf): S21_corr = S21 * pair
tx=unit(T_full).astype(np.complex64), # (TX, Nf): pair = tx[:,None] * rx[None,:]
rx=unit(R_full).astype(np.complex64), # (RX, Nf)
valid=valid, # (Nf,) True — внутри полосы калибровки
tx_output_pos=np.array(sorted(st.output_idx), np.int32), # индекс -> физический выход
rx_input_pos=np.array(sorted(st.input_idx), np.int32), # индекс -> физический вход
antenna_params=np.array([[np.radians(a['phase0_deg']), a['delay_path_m'], a['gain_db']] for a in ant.values()],
np.float64), # (TX+RX, 3): фаза к 0 Гц [рад], путь [м], усиление [дБ]
meta=meta, dir=None)
# ══════════════════════════════════════════════════════
# ФАЙЛЫ КАЛИБРОВКИ
# ══════════════════════════════════════════════════════
_CAL_ARRAYS = ('freq_hz', 'pair', 'tx', 'rx', 'valid', 'tx_output_pos', 'rx_input_pos', 'antenna_params')
def save_calibration(cal, cal_dir, overwrite=False):
cal_dir = Path(cal_dir)
cal_dir.mkdir(parents=True, exist_ok=True)
existing = [p.name for p in cal_dir.glob('cal_*')]
if existing and not overwrite:
raise FileExistsError(f'В {cal_dir} уже есть файлы калибровки ({existing[:3]}...). '
f'Задайте новое имя или overwrite=True.')
for k in _CAL_ARRAYS:
np.save(cal_dir / f'cal_{k}.npy', cal[k])
cal['meta']['name'] = cal_dir.name; cal['dir'] = cal_dir
json.dump(cal['meta'], open(cal_dir / 'cal_meta.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
return cal_dir
def load_calibration(cal_dir):
cal_dir = Path(cal_dir)
cal = {k: np.load(cal_dir / f'cal_{k}.npy') for k in _CAL_ARRAYS}
cal['meta'] = json.load(open(cal_dir / 'cal_meta.json', encoding='utf-8'))
cal['dir'] = cal_dir
return cal
# ══════════════════════════════════════════════════════
# ПРИМЕНЕНИЕ
# ══════════════════════════════════════════════════════
def calibration_on_grid(cal, freq):
"""Поправка пар (TX, RX, Nf) на частотной сетке freq. Совпадает с сеткой калибровки — берётся как есть;
иначе фаза и модуль поправок антенн интерполируются, вне сетки калибровки — линейная модель фазы."""
fc = cal['freq_hz']
if freq.shape == fc.shape and np.allclose(freq, fc):
return cal['pair']
def interp(Z, par):
ph = np.unwrap(np.angle(Z)); mag = np.abs(Z)
out_ph = np.interp(freq, fc, ph); out_mag = np.interp(freq, fc, mag)
lin = -(par[0] - 2 * np.pi * freq * par[1] / C_LIGHT) # поправка = −добавка
outside = (freq < fc[0]) | (freq > fc[-1])
out_ph[outside] = lin[outside] + np.round((out_ph[outside] - lin[outside]) / (2 * np.pi)) * 2 * np.pi
return out_mag * np.exp(1j * out_ph)
n_tx = cal['tx'].shape[0]
tx = np.array([interp(cal['tx'][k], cal['antenna_params'][k]) for k in range(n_tx)])
rx = np.array([interp(cal['rx'][k], cal['antenna_params'][n_tx + k]) for k in range(cal['rx'].shape[0])])
return (tx[:, None, :] * rx[None, :, :]).astype(cal['pair'].dtype)
def apply_calibration(s21, freq, cal):
"""s21 (TX, RX, Nf) в порядке cal['tx_output_pos'] x cal['rx_input_pos'] -> исправленный массив."""
return s21 * calibration_on_grid(cal, np.asarray(freq))
def apply_to_measurement(meas_dir, cal, overwrite=False):
"""<измерение>/preprocessed -> <измерение>/postprocessed: S21 пар умножаются на поправку,
остальные файлы копируются. Возвращает (папка, снимков, исправлено S21, скопировано файлов)."""
meas_dir = Path(meas_dir); pre = meas_dir / 'preprocessed'; post = meas_dir / 'postprocessed'
if not pre.is_dir():
raise FileNotFoundError(f'Нет {pre}')
# калибровка годится только для той же сборки антенн
tx_m, rx_m = load_geometry(find_config_profile(pre), cal['rx_input_pos'].tolist(), cal['tx_output_pos'].tolist())
if not (np.allclose(tx_m, cal['meta']['geometry']['tx_xyz_m']) and np.allclose(rx_m, cal['meta']['geometry']['rx_xyz_m'])):
raise ValueError(f'Геометрия антенн в {meas_dir.name} отличается от геометрии калибровки — применять нельзя')
if post.exists():
if not overwrite:
raise FileExistsError(f'{post} уже существует. overwrite=True, чтобы пересоздать.')
if not (post / 'calibration_applied.json').exists():
raise FileExistsError(f'{post} существует, но создан не калибровкой (нет calibration_applied.json) — '
f'удалять не буду, разберитесь вручную.')
shutil.rmtree(post)
tx_of = {int(p): k for k, p in enumerate(cal['tx_output_pos'])}
rx_of = {int(p): k for k, p in enumerate(cal['rx_input_pos'])}
pat = re.compile(r'^i(\d+)_o(\d+)_s21\.npy$')
n_snap = n_fix = n_copy = 0
grid_cache = {}
post.mkdir()
for item in sorted(pre.iterdir()):
if item.is_file():
shutil.copy2(item, post / item.name); n_copy += 1
continue
out = post / item.name; out.mkdir(); n_snap += 1
for f in sorted(item.iterdir()):
m = pat.match(f.name)
if f.is_dir() or m is None or int(m.group(2)) not in tx_of or int(m.group(1)) not in rx_of:
if f.is_dir():
shutil.copytree(f, out / f.name)
else:
shutil.copy2(f, out / f.name)
n_copy += 1
continue
s21 = np.load(f); freq = np.load(item / f'i{m.group(1)}_o{m.group(2)}_freq.npy')
key = (freq.size, float(freq[0]), float(freq[-1]))
if key not in grid_cache:
grid_cache[key] = calibration_on_grid(cal, freq)
corr = grid_cache[key][tx_of[int(m.group(2))], rx_of[int(m.group(1))]]
np.save(out / f.name, (s21 * corr).astype(s21.dtype)); n_fix += 1
json.dump(dict(calibration=cal['meta']['name'], calibration_dir=str(Path(cal['dir']).resolve()),
created=datetime.datetime.now().isoformat(timespec='seconds'), source=str(pre),
rule='postprocessed i*_o*_s21 = preprocessed i*_o*_s21 * cal_pair[tx(o), rx(i)]; остальные файлы скопированы',
frequency_grids=[list(k) for k in grid_cache]),
open(post / 'calibration_applied.json', 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
return post, n_snap, n_fix, n_copy
def main():
if RUN_CALIBRATION:
cal = compute_calibration(CAL_SETS, EMPTY_SCENE_PATH, Settings())
save_calibration(cal, CAL_DIR, overwrite=OVERWRITE)
q = cal['meta']['quality']
print(f'Калибровка записана в {CAL_DIR}: сфазированность пар на отражателе '
f'{np.mean(q["coherence_before"]):.2f} -> {np.mean(q["coherence_final"]):.2f}')
if APPLY_TO_DATA:
cal = load_calibration(CAL_DIR)
targets = APPLY_TO_MEASUREMENTS
if isinstance(targets, (str, Path)): # один путь вместо списка
targets = [targets]
for meas in targets:
post, n_snap, n_fix, _ = apply_to_measurement(meas, cal, OVERWRITE_POSTPROCESSED)
print(f'{Path(meas).name}: {n_snap} снимков, исправлено S21 {n_fix} -> {post}')
if __name__ == '__main__':
main()