запущен проект motor identification c терминалкой

This commit is contained in:
2026-06-05 12:15:36 +03:00
commit 177431f3d2
1383 changed files with 840275 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.log
*.tmp
out/

View File

@@ -0,0 +1,39 @@
# Идентификация двигателя через инвертор
Проект для расчёта параметров асинхронного двигателя по телеметрии инвертора без механической фиксации ротора.
Цель - разделить задачу на две части:
- безопасный эксперимент на приводе;
- автономная обработка CSV с измерениями.
## Что определяем
- `Rs` - активное сопротивление статора.
- `Rr` - приведённое сопротивление ротора.
- `Lls`, `Llr` - индуктивности рассеяния статора и ротора.
- `Lm` - взаимная индуктивность.
- `Ls`, `Lr`, `sigma`, `Tr` - производные параметры.
## Сценарий
1. Калибровка инвертора: смещения токов, `Udc`, мёртвое время, падения на ключах.
2. DC-тест `Rs` на нескольких уровнях тока.
3. Частотный AC-тест на свободном неподвижном валу.
4. Тест намагничивания `Lm`.
5. Подгонка T-образной схемы замещения по CSV.
## Структура
- `docs/experiment_protocol.md` - протокол экспериментов.
- `docs/model.md` - модель идентификации.
- `data/example_measurements.csv` - пример CSV.
- `tools/fit_ad_params.py` - расчёт параметров.
## Запуск
```powershell
python .\motor_id_inverter\tools\fit_ad_params.py .\motor_id_inverter\data\example_measurements.csv
```
Скрипт печатает JSON с оценками параметров и диагностикой качества.

View File

@@ -0,0 +1,14 @@
test,freq_hz,v_rms,i_rms,p_w,current_a,voltage_v,psi_wb,note
rs_dc,0,,,,5.0,0.92,,positive low
rs_dc,0,,,,-5.0,-0.96,,negative low
rs_dc,0,,,,10.0,1.88,,positive high
rs_dc,0,,,,-10.0,-1.92,,negative high
ac_sweep,1,2.270,10.0,18.40,,,,sweep point
ac_sweep,2,2.356,10.0,18.70,,,,sweep point
ac_sweep,3,2.492,10.0,19.10,,,,sweep point
ac_sweep,5,2.880,10.0,20.20,,,,sweep point
ac_sweep,10,4.090,10.0,24.90,,,,sweep point
magnetizing,0,,,,5.0,,0.330,low flux
magnetizing,0,,,,10.0,,0.630,nominal-ish flux
magnetizing,0,,,,15.0,,0.870,saturation starts
1 test freq_hz v_rms i_rms p_w current_a voltage_v psi_wb note
2 rs_dc 0 5.0 0.92 positive low
3 rs_dc 0 -5.0 -0.96 negative low
4 rs_dc 0 10.0 1.88 positive high
5 rs_dc 0 -10.0 -1.92 negative high
6 ac_sweep 1 2.270 10.0 18.40 sweep point
7 ac_sweep 2 2.356 10.0 18.70 sweep point
8 ac_sweep 3 2.492 10.0 19.10 sweep point
9 ac_sweep 5 2.880 10.0 20.20 sweep point
10 ac_sweep 10 4.090 10.0 24.90 sweep point
11 magnetizing 0 5.0 0.330 low flux
12 magnetizing 0 10.0 0.630 nominal-ish flux
13 magnetizing 0 15.0 0.870 saturation starts

View File

@@ -0,0 +1,69 @@
# Протокол экспериментов
## Безопасность
Перед тестом должны быть заданы:
- предел фазного тока;
- пределы DC-звена;
- предел скорости;
- предел температуры;
- таймаут теста.
Если нагрузка на валу может вызвать опасное движение, нужен тормоз, ограничитель перемещения или разомкнутая нагрузка.
## Калибровка инвертора
Перед идентификацией измеряются:
- нули датчиков фазных токов;
- масштаб `Udc`;
- реальное мёртвое время;
- падение напряжения на ключах и диодах.
Для малых токов ошибка восстановления напряжения может быть больше полезного сигнала.
## Определение `Rs`
Рекомендуемый опыт:
1. Подать `+I1`, дождаться установления, усреднить `U` и `I`.
2. Подать `-I1`, повторить.
3. Подать `+I2` и `-I2`, повторить.
4. Выполнить аппроксимацию `U = Rs * I + Uerr`.
Полярности `+I/-I` помогают отделить сопротивление обмотки от смещения инвертора.
## Частотный AC-тест без фиксации ротора
Используется пульсирующее поле по одной неподвижной оси:
- средний момент близок к нулю;
- вал не требуется фиксировать;
- частоты могут быть 1, 2, 3, 5, 10 Гц.
Для каждой частоты записываются `freq_hz`, `v_rms`, `i_rms`, `p_w`.
Комплексное сопротивление:
```text
R = P / I_rms^2
|Z| = V_rms / I_rms
X = sqrt(|Z|^2 - R^2)
Z = R + jX
```
## Намагничивание `Lm`
Для нескольких ступеней тока:
```text
psi = integral((V - Rs * I) dt)
Lm = psi / I - Lls
```
Лучше сохранять таблицу `Lm(I)`, а не одно число.
## Работа с нагрузкой
На неподвижном валу можно выполнять маломоментные тесты, если нагрузка безопасна. На ходу надёжно доуточняются только отдельные параметры, например `Rs` и `Rr/Tr`, и только при наличии тестового возбуждения.

View File

@@ -0,0 +1,53 @@
# Модель идентификации
## T-образная схема замещения
Для неподвижного ротора входное фазное сопротивление асинхронного двигателя описывается так:
```text
Z(jw) = Rs + jw*Lls + (jw*Lm || (Rr + jw*Llr))
```
где:
- `Rs` - сопротивление статора;
- `Rr` - приведённое сопротивление ротора;
- `Lls` - рассеяние статора;
- `Llr` - рассеяние ротора;
- `Lm` - взаимная индуктивность.
Если данных мало, допускается инженерное приближение:
```text
Lls = Llr = Ll / 2
```
Это не физический закон, а стартовое допущение для настройки регуляторов.
## Ограничения наблюдаемости
Без фиксации ротора и без тестового возбуждения нельзя надёжно разделить все параметры:
- `Rr` и `Lm` сильно связаны в низкочастотных данных;
- ошибка восстановления напряжения искажает `Rs`;
- насыщение делает `Lm` функцией тока;
- под нагрузкой момент нагрузки смешивается с параметрами ротора.
Поэтому используется двухэтапный подход:
1. предварительная идентификация на неподвижной машине;
2. доуточнение ограниченного набора параметров во время работы.
## Производные параметры
После оценки базовых параметров считаются:
```text
Ls = Lm + Lls
Lr = Lm + Llr
sigma = 1 - Lm^2 / (Ls * Lr)
Tr = Lr / Rr
```
Эти величины нужны для косвенного векторного управления.

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Estimate induction-motor parameters from inverter self-commissioning CSV.
The script intentionally uses only the Python standard library so it can run
on an engineering workstation without installing SciPy/Numpy.
"""
from __future__ import annotations
import csv
import json
import math
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
EPS = 1e-12
@dataclass
class SweepPoint:
freq_hz: float
r_ohm: float
x_ohm: float
@dataclass
class FitResult:
rs_ohm: float
rr_ohm: float
lls_h: float
llr_h: float
lm_h: float
rms_error_ohm: float
def parse_float(row: dict[str, str], key: str) -> float | None:
value = row.get(key, "")
if value is None or value.strip() == "":
return None
return float(value)
def load_rows(path: Path) -> list[dict[str, str]]:
with path.open("r", encoding="utf-8-sig", newline="") as stream:
return list(csv.DictReader(stream))
def linear_fit(xs: Iterable[float], ys: Iterable[float]) -> tuple[float, float]:
x = list(xs)
y = list(ys)
n = len(x)
if n < 2:
raise ValueError("linear fit needs at least two points")
sx = sum(x)
sy = sum(y)
sxx = sum(v * v for v in x)
sxy = sum(a * b for a, b in zip(x, y))
den = n * sxx - sx * sx
if abs(den) < EPS:
raise ValueError("linear fit has degenerate x values")
slope = (n * sxy - sx * sy) / den
offset = (sy - slope * sx) / n
return slope, offset
def estimate_rs(rows: list[dict[str, str]]) -> tuple[float, float]:
currents: list[float] = []
voltages: list[float] = []
for row in rows:
if row.get("test") != "rs_dc":
continue
current = parse_float(row, "current_a")
voltage = parse_float(row, "voltage_v")
if current is None or voltage is None:
continue
currents.append(current)
voltages.append(voltage)
if len(currents) < 2:
raise ValueError("need at least two rs_dc rows with current_a and voltage_v")
rs, voltage_offset = linear_fit(currents, voltages)
return max(rs, 0.0), voltage_offset
def estimate_sweep(rows: list[dict[str, str]]) -> list[SweepPoint]:
points: list[SweepPoint] = []
for row in rows:
if row.get("test") != "ac_sweep":
continue
freq = parse_float(row, "freq_hz")
v_rms = parse_float(row, "v_rms")
i_rms = parse_float(row, "i_rms")
power = parse_float(row, "p_w")
if freq is None or v_rms is None or i_rms is None or power is None:
continue
if freq <= 0.0 or i_rms <= EPS:
continue
z_abs = v_rms / i_rms
r = power / (i_rms * i_rms)
x_sq = max(z_abs * z_abs - r * r, 0.0)
points.append(SweepPoint(freq_hz=freq, r_ohm=r, x_ohm=math.sqrt(x_sq)))
if len(points) < 2:
raise ValueError("need at least two ac_sweep rows with freq_hz, v_rms, i_rms, p_w")
return points
def estimate_lm_from_flux(rows: list[dict[str, str]], lls_h: float) -> float | None:
values: list[float] = []
for row in rows:
if row.get("test") != "magnetizing":
continue
current = parse_float(row, "current_a")
psi = parse_float(row, "psi_wb")
if current is None or psi is None or abs(current) <= EPS:
continue
lm = psi / current - lls_h
if lm > 0.0:
values.append(lm)
if not values:
return None
return sum(values) / len(values)
def z_model(freq_hz: float, rs: float, rr: float, lls: float, llr: float, lm: float) -> complex:
w = 2.0 * math.pi * freq_hz
z_lm = 1j * w * lm
z_rotor = rr + 1j * w * llr
z_parallel = (z_lm * z_rotor) / (z_lm + z_rotor)
return rs + 1j * w * lls + z_parallel
def objective(points: list[SweepPoint], rs: float, rr: float, lls: float, llr: float, lm: float) -> float:
err = 0.0
for point in points:
z = z_model(point.freq_hz, rs, rr, lls, llr, lm)
scale = max(abs(complex(point.r_ohm, point.x_ohm)), 1e-3)
dr = (z.real - point.r_ohm) / scale
dx = (z.imag - point.x_ohm) / scale
err += dr * dr + dx * dx
return err / len(points)
def initial_from_sweep(points: list[SweepPoint], rs: float, lm_hint: float | None) -> tuple[float, float, float, float]:
r_slope, r0 = linear_fit([p.freq_hz for p in points], [p.r_ohm for p in points])
_ = r_slope
rr = max(r0 - rs, 1e-4)
leakage_values = []
for point in points:
w = 2.0 * math.pi * point.freq_hz
leakage_values.append(max(point.x_ohm / w, 1e-7))
ll_total = sum(leakage_values) / len(leakage_values)
lls = max(0.5 * ll_total, 1e-7)
llr = max(0.5 * ll_total, 1e-7)
lm = max(lm_hint if lm_hint is not None else 8.0 * ll_total, 5.0 * ll_total, 1e-6)
return rr, lls, llr, lm
def fit_t_model(points: list[SweepPoint], rs: float, lm_hint: float | None) -> FitResult:
rr, lls, llr, lm = initial_from_sweep(points, rs, lm_hint)
params = [rr, lls, llr, lm]
factors = [2.0, 2.0, 2.0, 2.0]
fitted_count = 3 if lm_hint is not None else 4
best = objective(points, rs, *params)
for _ in range(80):
improved = False
for idx in range(fitted_count):
for direction in (1.0, -1.0):
trial = params[:]
factor = factors[idx] if direction > 0.0 else 1.0 / factors[idx]
trial[idx] = max(trial[idx] * factor, 1e-9)
score = objective(points, rs, *trial)
if score < best:
params = trial
best = score
improved = True
if not improved:
factors = [math.sqrt(f) for f in factors]
if max(factors) < 1.005:
break
raw_error = 0.0
for point in points:
z = z_model(point.freq_hz, rs, *params)
raw_error += (z.real - point.r_ohm) ** 2 + (z.imag - point.x_ohm) ** 2
rms_error = math.sqrt(raw_error / len(points))
return FitResult(
rs_ohm=rs,
rr_ohm=params[0],
lls_h=params[1],
llr_h=params[2],
lm_h=params[3],
rms_error_ohm=rms_error,
)
def derived(result: FitResult) -> dict[str, float]:
ls = result.lm_h + result.lls_h
lr = result.lm_h + result.llr_h
sigma = 1.0 - (result.lm_h * result.lm_h) / max(ls * lr, EPS)
tr = lr / result.rr_ohm if result.rr_ohm > EPS else math.inf
return {
"Ls_H": ls,
"Lr_H": lr,
"Ll_total_H": result.lls_h + result.llr_h,
"sigma": sigma,
"Tr_s": tr,
}
def main(argv: list[str]) -> int:
if len(argv) != 2:
print(f"usage: {Path(argv[0]).name} measurements.csv", file=sys.stderr)
return 2
rows = load_rows(Path(argv[1]))
rs, voltage_offset = estimate_rs(rows)
points = estimate_sweep(rows)
rough_lls = initial_from_sweep(points, rs, None)[1]
lm_hint = estimate_lm_from_flux(rows, rough_lls)
fit = fit_t_model(points, rs, lm_hint)
payload = {
"parameters": {
"Rs_ohm": fit.rs_ohm,
"Rr_ohm": fit.rr_ohm,
"Lls_H": fit.lls_h,
"Llr_H": fit.llr_h,
"Lm_H": fit.lm_h,
**derived(fit),
},
"diagnostics": {
"rs_voltage_offset_V": voltage_offset,
"sweep_points": len(points),
"t_model_rms_error_ohm": fit.rms_error_ohm,
"lm_hint_used": lm_hint is not None,
"note": "Use as commissioning seed; validate against current-loop response before enabling torque control.",
},
}
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))