Browse Source

现在已经将所有电机方向和限位调试好,可以正常加载站立模型

corvin_zhang 3 weeks ago
parent
commit
a1022300bb

+ 113 - 1
guguji_real_robot/docs/real_robot_deployment_guide.md

@@ -214,6 +214,7 @@ guguji_real_robot/host/configs/guguji_real_robot.yaml
 - `GUGUJI_MOTOR_FEEDBACK_TIMEOUT_MS`:超过这个时间没有收到某个 RS00 的反馈,就在遥测中标记该关节反馈超时。
 - `GUGUJI_ARM_POSITION_LIMIT_MARGIN_RAD`:使能前当前反馈必须在 `robot_config.h` 限位附近。
 - `GUGUJI_MAX_COMMAND_POSITION_ERROR_RAD`:单帧目标和当前反馈差太大时,固件拒绝控制并进入 `ESTOP`。
+- `GUGUJI_ENABLE_CAN_DIAGNOSTICS`:CAN 诊断日志开关,默认 `0` 关闭;需要查看 `CAN诊断` 时改成 `1`。
 
 ## 9. 当前限制
 
@@ -233,7 +234,118 @@ guguji_real_robot/host/configs/guguji_real_robot.yaml
 - 足底接触或测距传感器,用于 base height。
 - 重新训练一个不依赖外部速度和高度的部署策略。
 
-## 10. 推荐调试顺序
+## 10. 站立到前进测试流程
+
+前提条件:
+
+- 8 个关节零位接近 0。
+- 左右腿方向已经完成标定,`robot_config.h` 已刷入 ESP32S3。
+- 机器人有保护架、吊带或人工手扶,电机电源可以立即断开。
+- 首次测试不要直接自由站立,更不要直接行走。
+
+### 10.1 只读确认
+
+```bash
+python guguji_real_robot/host/scripts/read_robot_state.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_stand.yaml \
+  --samples 5
+```
+
+通过标准:
+
+- `state=DISARMED`
+- `faults=ok`
+- 8 个关节 `pos_rad` 接近 0
+- roll/pitch 接近水平,温度正常
+
+### 10.2 悬空运行站立模型
+
+```bash
+MPLCONFIGDIR=/tmp/matplotlib python \
+  guguji_real_robot/host/scripts/run_real_policy.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_stand.yaml \
+  --model guguji_rl/outputs/balance_ppo_20260411_170346/final_model.zip \
+  --rl-config guguji_rl/outputs/balance_ppo_20260411_170346/resolved_config.yaml \
+  --deterministic \
+  --clear-faults \
+  --arm \
+  --confirm-calibrated \
+  --max-seconds 2 \
+  --log-interval 0.2
+```
+
+通过标准:
+
+- 没有突然大幅摆动。
+- `faults=ok`。
+- `joint_vel_max` 不持续偏大。
+- 电机温度没有快速升高。
+
+通过后逐步把 `--max-seconds` 改成 `5`、`10`。
+
+### 10.3 支撑架/吊带下站立承重
+
+让脚接触地面,但保护架或吊带仍承担大部分重量,先运行:
+
+```bash
+MPLCONFIGDIR=/tmp/matplotlib python \
+  guguji_real_robot/host/scripts/run_real_policy.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_stand.yaml \
+  --model guguji_rl/outputs/balance_ppo_20260411_170346/final_model.zip \
+  --rl-config guguji_rl/outputs/balance_ppo_20260411_170346/resolved_config.yaml \
+  --deterministic \
+  --clear-faults \
+  --arm \
+  --confirm-calibrated \
+  --max-seconds 3 \
+  --log-interval 0.2
+```
+
+通过后再逐步减少保护架承重,测试 `5s`、`10s`。如果机器人软塌,可以小幅提高
+`guguji_real_robot_stand.yaml` 的 `kp/kd`;如果抖动明显,先提高 `kd` 或降低 `kp`。
+
+### 10.4 低速过渡前进模型
+
+站立模型稳定后,优先测试 `forward_transition_ppo`,它比 walking 模型更保守。
+
+```bash
+MPLCONFIGDIR=/tmp/matplotlib python \
+  guguji_real_robot/host/scripts/run_real_policy.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_walk_cautious.yaml \
+  --model guguji_rl/outputs/forward_transition_ppo_20260411_171440/final_model.zip \
+  --rl-config guguji_rl/outputs/forward_transition_ppo_20260411_171440/resolved_config.yaml \
+  --deterministic \
+  --clear-faults \
+  --arm \
+  --confirm-calibrated \
+  --max-seconds 2 \
+  --log-interval 0.2
+```
+
+通过后再逐步测试 `3s`、`5s`。
+
+### 10.5 低速前进模型
+
+站立模型在保护架下稳定后,再测试 walking 模型。先不要自由行走,仍然使用吊带或保护架。
+
+```bash
+MPLCONFIGDIR=/tmp/matplotlib python \
+  guguji_real_robot/host/scripts/run_real_policy.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_walk_cautious.yaml \
+  --model guguji_rl/outputs/walk_ppo_20260412_183147/final_model.zip \
+  --rl-config guguji_rl/outputs/walk_ppo_20260412_183147/resolved_config.yaml \
+  --deterministic \
+  --clear-faults \
+  --arm \
+  --confirm-calibrated \
+  --max-seconds 2 \
+  --log-interval 0.2
+```
+
+通过后逐步测试 `3s`、`5s`。如果只是原地摆腿,先不要急着提高速度;当前真实机没有
+外部速度反馈,主机里的 `estimated_forward_velocity_m_s` 仍是常量。
+
+## 11. 推荐调试顺序
 
 1. 只刷固件,查看串口日志确认 WiFi、QMI8658A、QMC6309、蜂鸣器、WS2812 初始化。
 2. 不接电机,只运行主机脚本,确认 UDP 遥测正常。

+ 115 - 2
guguji_real_robot/docs/real_robot_joint_calibration_guide.md

@@ -89,21 +89,134 @@ upper_limit_rad
 ## 4. 单关节小步确认方向
 
 所有关节的中立姿态读数都进入合理范围后,再悬空执行单关节 jog。
-一次只测一个关节,角度从 `0.005rad` 或 `0.01rad` 开始。
+一次只测一个关节。零位已经确认正常后,方向标定可从 `0.03rad` 开始;
+如果仍然看不清,可尝试 `0.05rad`。脚本默认最大允许 `0.08rad`。
+
+机器人必须悬空或可靠支撑,手不要靠近连杆和脚掌,
+电机电源需要能立即断开。
+
+通用命令:
 
 ```bash
 python guguji_real_robot/host/scripts/joint_jog_test.py \
   --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml \
   --joint left_ankle_joint \
-  --delta-rad 0.01 \
+  --delta-rad 0.03 \
+  --duration 0.8 \
+  --ramp-seconds 1.2 \
+  --return-ramp-seconds 1.2 \
+  --settle-seconds 0.5 \
+  --joint-kp 12 \
+  --joint-kd 0.25 \
   --arm \
   --confirm-calibrated \
+  --ignore-attitude-safety \
   --clear-faults
 ```
 
+如果能听到电机进入控制状态的嗡嗡声,但关节几乎不动,通常是测试增益太小。
+可以先保持 `--joint-torque-ff-nm 0`,逐步尝试:
+
+```text
+--delta-rad 0.05 --joint-kp 12 --joint-kd 0.25
+--delta-rad 0.08 --joint-kp 18 --joint-kd 0.35
+```
+
+方向不确定前不要使用大的前馈力矩;`--joint-torque-ff-nm` 默认是 `0`。
+
+8 个关节建议按下面顺序逐个测试:
+
+```bash
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 1 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 2 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 3 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 4 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 5 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 6 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 7 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+python guguji_real_robot/host/scripts/joint_jog_test.py --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml --joint 8 --delta-rad 0.05 --duration 0.8 --ramp-seconds 1.2 --return-ramp-seconds 1.2 --settle-seconds 0.5 --joint-kp 12 --joint-kd 0.25 --arm --confirm-calibrated --ignore-attitude-safety --clear-faults
+```
+
+关节序号对应:
+
+```text
+1 left_hip_pitch_joint
+2 left_knee_pitch_joint
+3 left_ankle_pitch_joint
+4 left_ankle_joint
+5 right_hip_pitch_joint
+6 right_knee_pitch_joint
+7 right_ankle_pitch_joint
+8 right_ankle_joint
+```
+
+判断方法:
+
+- 终端里该关节 `pos` 应该从接近 `0` 缓慢变化到目标角附近,然后回到起始值。
+- 真实机构的运动方向要和仿真模型中该关节正方向一致;如果不确定正方向,先只记录运动方向,不要加大角度。
+- 可以再用负的 `--delta-rad` 测一次,真实机构应该做完全相反的小动作。
+
+1 号 `left_hip_pitch_joint` 的判断:
+
+- 按当前 `guguji.urdf` 的关节轴定义,1 号关节正方向应表现为左腿朝机器人中心方向小幅内收。
+- 如果 `--delta-rad 0.05` 时左腿朝外侧远离身体摆动,1 号关节方向与仿真相反。
+- 用 `--delta-rad -0.05` 再确认一次;如果负方向变成内收,就把 `robot_config.h` 中 1 号关节的 `direction` 改成 `-1.0f`。
+- 髋关节带整条腿,方向确认时可临时使用 `--joint-kp 20 --joint-kd 0.45`,必要时把 `--max-joint-velocity-rad-s` 临时设为 `1.2`。
+- 当前真机标定记录:1 号正方向测试时左腿外摆,因此 `direction` 已改为 `-1.0f`。
+
+2 号 `left_knee_pitch_joint` 的判断:
+
+- 按当前 `guguji.urdf` 的关节轴定义,`--delta-rad -0.05` 是左膝让小腿/脚上抬的方向。
+- 如果负方向抬不起来,先不要判断为方向错误;2 号关节负载更大,可逐步提高测试角度和被测关节 Kp。
+- 推荐先试 `--delta-rad -0.10 --max-delta-rad 0.15 --joint-kp 30 --joint-kd 0.60 --max-joint-velocity-rad-s 1.5`。
+- 如果终端里的 `target` 已经变化,但 `pos` 基本不跟随,说明力矩仍不足、机构卡滞或该方向被机械限位阻挡。
+- 如果 `--delta-rad +0.10` 反而让小腿/脚上抬,则 2 号关节方向相反,需要把 `robot_config.h` 中 2 号关节的 `direction` 改成 `-1.0f`。
+
+3 号 `left_ankle_pitch_joint` 的判断:
+
+- 按当前 `guguji.urdf` 的关节轴定义,`--delta-rad -0.10` 会让左脚/小腿朝后下方弯曲。
+- 如果负方向 jog 时左腿往后面弯曲,3 号关节方向是正确的。
+- 用 `--delta-rad +0.10` 验证时,左脚/小腿应朝相反方向运动。
+- 如果正负方向观察结果和上述相反,则需要把 `robot_config.h` 中 3 号关节的 `direction` 改成 `-1.0f`。
+
+4 号 `left_ankle_joint` 的判断:
+
+- 当前脚本中 `--delta-rad 0.05` 表示给 4 号关节发送正方向目标。
+- 按当前 `guguji.urdf` 的关节轴定义,左脚 4 号关节正方向应表现为脚尖上抬。
+- 因此,如果正向 jog 时左脚脚尖上抬,4 号关节方向是正确的。
+- 如果正向 jog 时左脚脚尖下压,则需要把 `robot_config.h` 中 4 号关节的 `direction` 改成 `-1.0f`。
+- 再用 `--delta-rad -0.05` 验证一次,左脚脚尖应下压或向相反方向运动。
+
+右腿 5~8 号镜像推导:
+
+- 当前 URDF 中,右腿关节轴已经相对左腿做了镜像:5 号为 `+X`,6/7/8 号为 `+Y`。
+- 左腿实测结果为:1 号反向,2/3/4 号默认方向正确。
+- 如果右腿电机和机构是严格镜像安装,则推荐映射为:5 号跟 1 号一样反向,6/7/8 号保持默认方向。
+- 当前真机标定记录:5 号 `direction` 已改为 `-1.0f`;6/7/8 号仍为 `1.0f`。
+- 后续如果整机测试发现右腿某个关节动作与仿真相反,再单独用 `joint_jog_test.py` 验证并修改,不建议把 5~8 一次性全部反向。
+
 如果正向 jog 和仿真定义的正方向相反,把该关节 `direction` 改成 `-1.0f`,
 然后按照第 2 节公式重新确认 `zero_offset_rad`。
 
+### ESTOP 或红灯报警恢复
+
+如果脚本输出 `state=ESTOP`、红灯快闪、蜂鸣器间隔报警,先不要继续 jog。
+
+1. 断开或确认电机动力电源处于安全状态,检查机构是否卡住。
+2. 让机身回到接近水平的姿态,台架配置默认要求 `roll/pitch` 小于约 `0.35rad`。
+3. 检查电机动力电源和 CAN 总线连接,`fault=0x0000FF00` 表示 8 个电机反馈都超时。
+   `joint_jog_test.py` 会在使能前等待几秒让反馈恢复;如果仍然保持该故障码,
+   说明不是姿态角问题,而是电机反馈链路没有恢复。
+4. 运行只读遥测恢复到失能状态:
+
+```bash
+python guguji_real_robot/host/scripts/read_robot_state.py \
+  --deploy-config guguji_real_robot/host/configs/guguji_real_robot_bench.yaml \
+  --samples 5
+```
+
+只有看到 `state=DISARMED` 且 `faults=ok` 后,才继续单关节 jog。
+
 ## 5. 再跑整机策略
 
 只有在以下条件全部满足后,才运行 `run_real_policy.py --arm --confirm-calibrated`:

+ 4 - 1
guguji_real_robot/firmware/esp32s3_espidf/README.md

@@ -124,4 +124,7 @@ python3 host/scripts/ota_update.py \
 
 如果遥测显示 `fault_mask=0x0000FF00`,代表 8 个 RS00 都没有反馈。优先检查电机动力电、CANH/CANL、GND 共地、两端 120Ω 终端电阻、1Mbps 波特率、MIT 协议,以及每个电机 CAN ID 是否与 `main/robot_config.h` 一致。固件启动日志中如果出现“发送主动上报配置失败”或“发送停止帧失败”,通常表示 CAN 总线没有收到 ACK。
 
-固件会每 2 秒打印 `CAN诊断`。`raw_rx=0` 表示 ESP32 没收到任何标准 CAN 数据帧;`raw_rx>0` 但 `parsed_feedback=0` 通常表示收到了非当前 RS00 MIT 反馈格式或未配置 CAN ID 的帧。
+周期性 `CAN诊断` 日志默认关闭,避免串口输出过多。需要排查 CAN 总线时,把
+`main/config.h` 中的 `GUGUJI_ENABLE_CAN_DIAGNOSTICS` 改成 `1` 后重新编译刷写。
+打开后会每 2 秒打印一次;`raw_rx=0` 表示 ESP32 没收到任何标准 CAN 数据帧,
+`raw_rx>0` 但 `parsed_feedback=0` 通常表示收到了非当前 RS00 MIT 反馈格式或未配置 CAN ID 的帧。

+ 6 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/app_main.c

@@ -203,6 +203,7 @@ static bool command_targets_are_near_feedback(const guguji_command_packet_t *com
     return safe;
 }
 
+#if GUGUJI_ENABLE_CAN_DIAGNOSTICS
 static const char *twai_state_name(int state)
 {
     switch (state) {
@@ -218,6 +219,7 @@ static const char *twai_state_name(int state)
         return "unknown";
     }
 }
+#endif
 
 static void build_joint_mask_names(uint32_t joint_mask, char *buffer, size_t buffer_size)
 {
@@ -536,6 +538,7 @@ static void can_feedback_task(void *arg)
     }
 }
 
+#if GUGUJI_ENABLE_CAN_DIAGNOSTICS
 static void can_diagnostics_task(void *arg)
 {
     (void)arg;
@@ -582,6 +585,7 @@ static void can_diagnostics_task(void *arg)
         vTaskDelay(pdMS_TO_TICKS(2000));
     }
 }
+#endif
 
 static void sensor_task(void *arg)
 {
@@ -881,7 +885,9 @@ void app_main(void)
     xTaskCreatePinnedToCore(udp_receive_task, "udp_rx", 4096, NULL, 6, NULL, 0);
     xTaskCreatePinnedToCore(control_task, "control", 4096, NULL, 8, NULL, 1);
     xTaskCreatePinnedToCore(can_feedback_task, "can_rx", 4096, NULL, 7, NULL, 1);
+#if GUGUJI_ENABLE_CAN_DIAGNOSTICS
     xTaskCreatePinnedToCore(can_diagnostics_task, "can_diag", 4096, NULL, 4, NULL, 1);
+#endif
     xTaskCreatePinnedToCore(sensor_task, "sensors", 4096, NULL, 5, NULL, 0);
     xTaskCreatePinnedToCore(telemetry_task, "telemetry", 4096, NULL, 5, NULL, 0);
     xTaskCreatePinnedToCore(status_task, "status", 4096, NULL, 4, NULL, 0);

+ 7 - 0
guguji_real_robot/firmware/esp32s3_espidf/main/config.h

@@ -40,6 +40,13 @@
 // QMC6309 数据手册给出的 7-bit 地址为 0x7C。
 #define GUGUJI_QMC6309_ADDR 0x7C
 
+// ==============================
+// 调试日志开关
+// ==============================
+// 默认关闭周期性 CAN 诊断日志,避免串口输出过多。
+// 需要排查 CAN 总线时改成 1,重新编译刷写后会恢复打印 id_counts/错误计数。
+#define GUGUJI_ENABLE_CAN_DIAGNOSTICS 0
+
 // ==============================
 // 控制周期和安全参数
 // ==============================

+ 8 - 8
guguji_real_robot/firmware/esp32s3_espidf/main/robot_config.h

@@ -18,12 +18,12 @@ typedef struct {
 // motor_angle = joint_angle * direction + zero_offset_rad。
 // 真机第一次测试时务必悬空,逐个关节确认正方向后再修改 direction。
 static const guguji_joint_config_t GUGUJI_JOINTS[GUGUJI_JOINT_COUNT] = {
-    {"left_hip_pitch_joint", 1, 1.0f, 0.0f, -1.0f, 1.0f},
-    {"left_knee_pitch_joint", 2, 1.0f, 0.0f, -1.2f, 1.2f},
-    {"left_ankle_pitch_joint", 3, 1.0f, 0.0f, -0.8f, 0.8f},
-    {"left_ankle_joint", 4, 1.0f, 0.0f, -0.6f, 0.6f},
-    {"right_hip_pitch_joint", 5, 1.0f, 0.0f, -1.0f, 1.0f},
-    {"right_knee_pitch_joint", 6, 1.0f, 0.0f, -1.2f, 1.2f},
-    {"right_ankle_pitch_joint", 7, 1.0f, 0.0f, -0.8f, 0.8f},
-    {"right_ankle_joint", 8, 1.0f, 0.0f, -0.6f, 0.6f},
+    {"left_hip_pitch_joint", 1, -1.0f, 5.289f, -1.0f, 1.0f},
+    {"left_knee_pitch_joint", 2, 1.0f, 2.750f, -1.2f, 1.2f},
+    {"left_ankle_pitch_joint", 3, 1.0f, 0.985f, -0.8f, 0.8f},
+    {"left_ankle_joint", 4, 1.0f, 5.911f, -0.6f, 0.6f},
+    {"right_hip_pitch_joint", 5, -1.0f, 0.765f, -1.0f, 1.0f},
+    {"right_knee_pitch_joint", 6, 1.0f, 2.167f, -1.2f, 1.2f},
+    {"right_ankle_pitch_joint", 7, 1.0f, 3.443f, -0.8f, 0.8f},
+    {"right_ankle_joint", 8, 1.0f, 1.795f, -0.6f, 0.6f},
 };

+ 30 - 0
guguji_real_robot/host/configs/guguji_real_robot_stand.yaml

@@ -0,0 +1,30 @@
+# guguji 真机站立测试配置
+# 用途:零位/方向完成后,先在悬空、支撑架或手扶状态下测试 balance 模型。
+
+network:
+  esp32_host: 192.168.4.1
+  command_port: 7777
+  telemetry_bind_host: 0.0.0.0
+  telemetry_port: 7778
+  telemetry_timeout_s: 1.0
+
+control:
+  # 比 bench 配置更有支撑力,但低于默认配置,适合站立阶段逐步承重。
+  kp: [16.0, 20.0, 14.0, 10.0, 16.0, 20.0, 14.0, 10.0]
+  kd: [0.35, 0.45, 0.35, 0.25, 0.35, 0.45, 0.35, 0.25]
+
+safety:
+  max_joint_step_rad: 0.008
+  max_roll_rad: 0.55
+  max_pitch_rad: 0.55
+  max_joint_velocity_rad_s: 2.0
+  arm_position_limit_margin_rad: 0.08
+  run_position_limit_margin_rad: 0.12
+
+state_estimation:
+  estimated_base_height_m: 0.35
+  estimated_forward_velocity_m_s: 0.0
+  target_forward_velocity_m_s: 0.0
+
+logging:
+  status_interval_s: 0.2

+ 31 - 0
guguji_real_robot/host/configs/guguji_real_robot_walk_cautious.yaml

@@ -0,0 +1,31 @@
+# guguji 真机低速前进测试配置
+# 用途:站立稳定后,在保护架/吊带下短时测试 walking 模型。
+
+network:
+  esp32_host: 192.168.4.1
+  command_port: 7777
+  telemetry_bind_host: 0.0.0.0
+  telemetry_port: 7778
+  telemetry_timeout_s: 1.0
+
+control:
+  # 前进测试需要比站立略强的支撑,但仍保守于默认配置。
+  kp: [22.0, 24.0, 16.0, 10.0, 22.0, 24.0, 16.0, 10.0]
+  kd: [0.45, 0.55, 0.40, 0.30, 0.45, 0.55, 0.40, 0.30]
+
+safety:
+  max_joint_step_rad: 0.012
+  max_roll_rad: 0.65
+  max_pitch_rad: 0.65
+  max_joint_velocity_rad_s: 2.5
+  arm_position_limit_margin_rad: 0.08
+  run_position_limit_margin_rad: 0.15
+
+state_estimation:
+  estimated_base_height_m: 0.35
+  estimated_forward_velocity_m_s: 0.0
+  # 当前没有外部速度估计,先给低目标速度做保守前进测试。
+  target_forward_velocity_m_s: 0.12
+
+logging:
+  status_interval_s: 0.2

+ 218 - 27
guguji_real_robot/host/scripts/joint_jog_test.py

@@ -34,12 +34,20 @@ def parse_args() -> argparse.Namespace:
     parser.add_argument('--deploy-config', default=str(HOST_ROOT / 'configs' / 'guguji_real_robot_bench.yaml'))
     parser.add_argument('--rl-config', default=str(PROJECT_ROOT / 'guguji_rl' / 'configs' / 'balance_ppo.yaml'))
     parser.add_argument('--joint', required=True, help='关节名,或 1~8 的序号')
-    parser.add_argument('--delta-rad', type=float, default=0.015, help='小步测试角度,建议 0.005~0.02rad')
+    parser.add_argument('--delta-rad', type=float, default=0.03, help='小步测试角度,建议 0.03~0.05rad')
+    parser.add_argument('--max-delta-rad', type=float, default=0.08, help='允许的最大测试角度绝对值,默认 0.08rad')
     parser.add_argument('--duration', type=float, default=0.8, help='保持小步目标的时间,单位秒')
     parser.add_argument('--ramp-seconds', type=float, default=0.6, help='从当前位置渐变到目标的时间,单位秒')
+    parser.add_argument('--return-ramp-seconds', type=float, default=None, help='从目标位置渐变回起始位置的时间,默认等于 ramp-seconds')
     parser.add_argument('--settle-seconds', type=float, default=0.6, help='小步前后保持当前位置的时间,单位秒')
+    parser.add_argument('--ready-timeout', type=float, default=4.0, help='使能前等待 faults=ok 的最长时间,单位秒')
+    parser.add_argument('--joint-kp', type=float, default=None, help='临时覆盖被测关节 Kp,用于方向标定')
+    parser.add_argument('--joint-kd', type=float, default=None, help='临时覆盖被测关节 Kd,用于方向标定')
+    parser.add_argument('--joint-torque-ff-nm', type=float, default=0.0, help='被测关节前馈力矩,方向不确定前建议保持 0')
+    parser.add_argument('--max-joint-velocity-rad-s', type=float, default=None, help='临时覆盖关节速度保护阈值')
     parser.add_argument('--arm', action='store_true', help='真正使能并执行 jog;不加时只打印遥测')
     parser.add_argument('--confirm-calibrated', action='store_true', help='确认零位已在限位内后允许使能')
+    parser.add_argument('--ignore-attitude-safety', action='store_true', help='方向标定时忽略 roll/pitch 检查,仍检查故障和关节速度')
     parser.add_argument('--clear-faults', action='store_true', help='启动时请求 ESP32S3 清除 RS00 故障')
     return parser.parse_args()
 
@@ -62,8 +70,10 @@ def send_command(
     kp: np.ndarray,
     kd: np.ndarray,
     flags: int,
+    torque_ff_nm: np.ndarray | None = None,
 ) -> int:
     zero = np.zeros_like(positions_rad, dtype=np.float32)
+    torque = zero if torque_ff_nm is None else torque_ff_nm.astype(np.float32)
     link.send_command(
         CommandPacket(
             sequence=sequence,
@@ -71,7 +81,7 @@ def send_command(
             velocities_rad_s=zero,
             kp=kp.astype(np.float32),
             kd=kd.astype(np.float32),
-            torque_ff_nm=zero,
+            torque_ff_nm=torque,
             flags=flags,
         )
     )
@@ -84,8 +94,12 @@ def send_stop(
     sequence: int,
     positions_rad: np.ndarray,
     repeats: int = 10,
+    estop: bool = True,
 ) -> int:
     zero = np.zeros_like(positions_rad, dtype=np.float32)
+    flags = COMMAND_FLAG_DISARM | COMMAND_FLAG_HOLD
+    if estop:
+        flags |= COMMAND_FLAG_ESTOP
     for _ in range(repeats):
         sequence = send_command(
             link,
@@ -93,7 +107,7 @@ def send_stop(
             positions_rad=positions_rad,
             kp=zero,
             kd=zero,
-            flags=COMMAND_FLAG_DISARM | COMMAND_FLAG_HOLD | COMMAND_FLAG_ESTOP,
+            flags=flags,
         )
         time.sleep(0.02)
     return sequence
@@ -106,12 +120,13 @@ def assert_safe(
     max_roll_rad: float,
     max_pitch_rad: float,
     max_joint_velocity_rad_s: float,
+    ignore_attitude_safety: bool = False,
 ) -> None:
     if telemetry.fault_mask != 0:
         raise RuntimeError(f'ESP32S3 fault_mask=0x{telemetry.fault_mask:08X}')
-    if abs(telemetry.roll) > max_roll_rad:
+    if not ignore_attitude_safety and abs(telemetry.roll) > max_roll_rad:
         raise RuntimeError(f'roll 超限 {telemetry.roll:.3f}rad')
-    if abs(telemetry.pitch) > max_pitch_rad:
+    if not ignore_attitude_safety and abs(telemetry.pitch) > max_pitch_rad:
         raise RuntimeError(f'pitch 超限 {telemetry.pitch:.3f}rad')
     max_velocity_index = int(np.argmax(np.abs(telemetry.joint_velocity_rad_s)))
     max_velocity = float(abs(telemetry.joint_velocity_rad_s[max_velocity_index]))
@@ -122,19 +137,95 @@ def assert_safe(
         )
 
 
-def print_joint_snapshot(telemetry: TelemetryPacket, joint_names: list[str], selected_index: int) -> None:
+def print_joint_snapshot(
+    telemetry: TelemetryPacket,
+    joint_names: list[str],
+    selected_index: int,
+    *,
+    target_rad: float | None = None,
+) -> None:
+    target_text = ''
+    if target_rad is not None:
+        error_rad = float(target_rad) - float(telemetry.joint_position_rad[selected_index])
+        target_text = f' target={target_rad:.4f}rad err={error_rad:.4f}rad'
     print(
         f'{joint_names[selected_index]}: '
         f'pos={telemetry.joint_position_rad[selected_index]:.4f}rad '
         f'vel={telemetry.joint_velocity_rad_s[selected_index]:.4f}rad/s '
         f'state={telemetry.robot_state_name} fault=0x{telemetry.fault_mask:08X}'
+        f'{target_text}'
     )
 
 
+def wait_for_ready_telemetry(
+    link: UdpRobotLink,
+    *,
+    sequence: int,
+    hold_positions: np.ndarray,
+    joint_count: int,
+    timeout_s: float,
+    require_faults_ok: bool,
+    clear_faults: bool,
+) -> tuple[int, TelemetryPacket]:
+    zero = np.zeros(joint_count, dtype=np.float32)
+    deadline = time.monotonic() + max(timeout_s, 0.0)
+    last_log_time = 0.0
+    last_telemetry: TelemetryPacket | None = None
+
+    while True:
+        flags = COMMAND_FLAG_DISARM | COMMAND_FLAG_HOLD
+        if clear_faults:
+            flags |= COMMAND_FLAG_CLEAR_FAULTS
+        sequence = send_command(
+            link,
+            sequence=sequence,
+            positions_rad=hold_positions,
+            kp=zero,
+            kd=zero,
+            flags=flags,
+        )
+        try:
+            telemetry = link.receive_telemetry()
+        except TimeoutError:
+            if time.monotonic() >= deadline:
+                raise
+            continue
+
+        last_telemetry = telemetry
+        if not require_faults_ok or telemetry.fault_mask == 0:
+            return sequence, telemetry
+
+        now = time.monotonic()
+        if now - last_log_time >= 0.8:
+            print(
+                '等待电机反馈恢复: '
+                f'state={telemetry.robot_state_name} '
+                f'fault=0x{telemetry.fault_mask:08X} '
+                f'cmd_age={telemetry.last_command_age_ms}ms'
+            )
+            last_log_time = now
+        if now >= deadline:
+            return sequence, telemetry
+        time.sleep(0.05)
+
+
 def main() -> int:
     args = parse_args()
-    if abs(args.delta_rad) > 0.03:
-        print('安全拦截:--delta-rad 绝对值不能超过 0.03rad。')
+    max_delta_rad = abs(float(args.max_delta_rad))
+    if max_delta_rad <= 0.0:
+        print('安全拦截:--max-delta-rad 必须大于 0。')
+        return 2
+    if abs(args.delta_rad) > max_delta_rad:
+        print(f'安全拦截:--delta-rad 绝对值不能超过 {max_delta_rad:.3f}rad。')
+        return 2
+    if args.joint_kp is not None and not (0.0 <= float(args.joint_kp) <= 50.0):
+        print('安全拦截:--joint-kp 必须在 0~50 之间。')
+        return 2
+    if args.joint_kd is not None and not (0.0 <= float(args.joint_kd) <= 2.0):
+        print('安全拦截:--joint-kd 必须在 0~2 之间。')
+        return 2
+    if abs(float(args.joint_torque_ff_nm)) > 2.0:
+        print('安全拦截:--joint-torque-ff-nm 绝对值不能超过 2Nm。')
         return 2
     if args.arm and not args.confirm_calibrated:
         print('安全拦截:执行 jog 需要同时添加 --arm 和 --confirm-calibrated。')
@@ -149,6 +240,14 @@ def main() -> int:
 
     kp = np.asarray(control_config['kp'], dtype=np.float32)
     kd = np.asarray(control_config['kd'], dtype=np.float32)
+    jog_kp = kp.copy()
+    jog_kd = kd.copy()
+    jog_torque = np.zeros(len(mapper.joint_names), dtype=np.float32)
+    if args.joint_kp is not None:
+        jog_kp[selected_index] = float(args.joint_kp)
+    if args.joint_kd is not None:
+        jog_kd[selected_index] = float(args.joint_kd)
+    jog_torque[selected_index] = float(args.joint_torque_ff_nm)
     link = UdpRobotLink(
         command_host=str(network_config['esp32_host']),
         command_port=int(network_config['command_port']),
@@ -161,35 +260,67 @@ def main() -> int:
     hold = mapper.nominal_joint_targets.astype(np.float32)
     max_roll_rad = float(safety_config['max_roll_rad'])
     max_pitch_rad = float(safety_config['max_pitch_rad'])
-    max_joint_velocity_rad_s = float(safety_config.get('max_joint_velocity_rad_s', 0.8))
+    max_joint_velocity_rad_s = float(
+        args.max_joint_velocity_rad_s
+        if args.max_joint_velocity_rad_s is not None
+        else safety_config.get('max_joint_velocity_rad_s', 0.8)
+    )
+    armed_started = False
+    request_estop_on_exit = False
 
     try:
-        for _ in range(5):
-            sequence = send_command(
-                link,
-                sequence=sequence,
-                positions_rad=hold,
-                kp=np.zeros_like(kp),
-                kd=np.zeros_like(kd),
-                flags=COMMAND_FLAG_DISARM | COMMAND_FLAG_HOLD,
-            )
-            time.sleep(0.05)
-
-        telemetry = link.receive_telemetry()
+        sequence, telemetry = wait_for_ready_telemetry(
+            link,
+            sequence=sequence,
+            hold_positions=hold,
+            joint_count=len(mapper.joint_names),
+            timeout_s=float(args.ready_timeout),
+            require_faults_ok=args.arm,
+            clear_faults=args.clear_faults,
+        )
         hold = telemetry.joint_position_rad.astype(np.float32).copy()
         print('当前反馈:')
         print_joint_snapshot(telemetry, mapper.joint_names, selected_index)
+        print(
+            f'被测关节临时控制参数: '
+            f'kp={jog_kp[selected_index]:.3f} '
+            f'kd={jog_kd[selected_index]:.3f} '
+            f'torque_ff={jog_torque[selected_index]:.3f}Nm'
+        )
         if not args.arm:
             print('未添加 --arm,只读取遥测,不执行 jog。')
             return 0
 
+        try:
+            assert_safe(
+                telemetry,
+                joint_names=mapper.joint_names,
+                max_roll_rad=max_roll_rad,
+                max_pitch_rad=max_pitch_rad,
+                max_joint_velocity_rad_s=max_joint_velocity_rad_s,
+                ignore_attitude_safety=args.ignore_attitude_safety,
+            )
+        except RuntimeError as exc:
+            print(f'禁止使能: {exc}')
+            print('请先恢复到 DISARMED/faults=ok;如果只是姿态角超限,可在方向标定时添加 --ignore-attitude-safety。')
+            return 3
+
         flags = COMMAND_FLAG_ARM
         if args.clear_faults:
             flags |= COMMAND_FLAG_CLEAR_FAULTS
 
         start = time.monotonic()
         while time.monotonic() - start < max(args.settle_seconds, 0.0):
-            sequence = send_command(link, sequence=sequence, positions_rad=hold, kp=kp, kd=kd, flags=flags)
+            sequence = send_command(
+                link,
+                sequence=sequence,
+                positions_rad=hold,
+                kp=jog_kp,
+                kd=jog_kd,
+                torque_ff_nm=jog_torque,
+                flags=flags,
+            )
+            armed_started = True
             telemetry = link.receive_telemetry()
             assert_safe(
                 telemetry,
@@ -197,6 +328,7 @@ def main() -> int:
                 max_roll_rad=max_roll_rad,
                 max_pitch_rad=max_pitch_rad,
                 max_joint_velocity_rad_s=max_joint_velocity_rad_s,
+                ignore_attitude_safety=args.ignore_attitude_safety,
             )
 
         jog_target = hold.copy()
@@ -213,7 +345,49 @@ def main() -> int:
             progress = min(elapsed / max(args.ramp_seconds, 0.05), 1.0)
             target = hold.copy()
             target[selected_index] = hold[selected_index] + float(args.delta_rad) * progress
-            sequence = send_command(link, sequence=sequence, positions_rad=target, kp=kp, kd=kd, flags=COMMAND_FLAG_ARM)
+            sequence = send_command(
+                link,
+                sequence=sequence,
+                positions_rad=target,
+                kp=jog_kp,
+                kd=jog_kd,
+                torque_ff_nm=jog_torque,
+                flags=COMMAND_FLAG_ARM,
+            )
+            armed_started = True
+            telemetry = link.receive_telemetry()
+            assert_safe(
+                telemetry,
+                joint_names=mapper.joint_names,
+                max_roll_rad=max_roll_rad,
+                max_pitch_rad=max_pitch_rad,
+                max_joint_velocity_rad_s=max_joint_velocity_rad_s,
+                ignore_attitude_safety=args.ignore_attitude_safety,
+            )
+            print_joint_snapshot(
+                telemetry,
+                mapper.joint_names,
+                selected_index,
+                target_rad=float(target[selected_index]),
+            )
+
+        return_start = telemetry.joint_position_rad.astype(np.float32).copy()
+        return_ramp_seconds = args.ramp_seconds if args.return_ramp_seconds is None else float(args.return_ramp_seconds)
+        start = time.monotonic()
+        while time.monotonic() - start < max(return_ramp_seconds, 0.05):
+            elapsed = time.monotonic() - start
+            progress = min(elapsed / max(return_ramp_seconds, 0.05), 1.0)
+            target = return_start + (hold - return_start) * progress
+            sequence = send_command(
+                link,
+                sequence=sequence,
+                positions_rad=target,
+                kp=jog_kp,
+                kd=jog_kd,
+                torque_ff_nm=jog_torque,
+                flags=COMMAND_FLAG_ARM,
+            )
+            armed_started = True
             telemetry = link.receive_telemetry()
             assert_safe(
                 telemetry,
@@ -221,12 +395,27 @@ def main() -> int:
                 max_roll_rad=max_roll_rad,
                 max_pitch_rad=max_pitch_rad,
                 max_joint_velocity_rad_s=max_joint_velocity_rad_s,
+                ignore_attitude_safety=args.ignore_attitude_safety,
+            )
+            print_joint_snapshot(
+                telemetry,
+                mapper.joint_names,
+                selected_index,
+                target_rad=float(target[selected_index]),
             )
-            print_joint_snapshot(telemetry, mapper.joint_names, selected_index)
 
         start = time.monotonic()
         while time.monotonic() - start < max(args.settle_seconds, 0.0):
-            sequence = send_command(link, sequence=sequence, positions_rad=hold, kp=kp, kd=kd, flags=COMMAND_FLAG_ARM)
+            sequence = send_command(
+                link,
+                sequence=sequence,
+                positions_rad=hold,
+                kp=jog_kp,
+                kd=jog_kd,
+                torque_ff_nm=jog_torque,
+                flags=COMMAND_FLAG_ARM,
+            )
+            armed_started = True
             telemetry = link.receive_telemetry()
             assert_safe(
                 telemetry,
@@ -234,14 +423,16 @@ def main() -> int:
                 max_roll_rad=max_roll_rad,
                 max_pitch_rad=max_pitch_rad,
                 max_joint_velocity_rad_s=max_joint_velocity_rad_s,
+                ignore_attitude_safety=args.ignore_attitude_safety,
             )
         print('jog 完成,已回到起始保持位置。')
     except (KeyboardInterrupt, TimeoutError, RuntimeError) as exc:
         print(f'测试中止: {exc}')
-        sequence = send_stop(link, sequence=sequence, positions_rad=hold)
+        request_estop_on_exit = armed_started
+        sequence = send_stop(link, sequence=sequence, positions_rad=hold, estop=request_estop_on_exit)
         return 4
     finally:
-        send_stop(link, sequence=sequence, positions_rad=hold, repeats=5)
+        send_stop(link, sequence=sequence, positions_rad=hold, repeats=5, estop=request_estop_on_exit)
         link.close()
     return 0
 

+ 94 - 19
guguji_real_robot/host/scripts/run_real_policy.py

@@ -41,6 +41,12 @@ def parse_args() -> argparse.Namespace:
     parser.add_argument('--max-seconds', type=float, default=0.0, help='运行时长,0 表示一直运行')
     parser.add_argument('--deterministic', action='store_true', help='使用确定性策略输出')
     parser.add_argument('--log-interval', type=float, default=None, help='ESP32S3 遥测日志间隔,单位秒')
+    parser.add_argument(
+        '--ready-timeout',
+        type=float,
+        default=6.0,
+        help='--arm 前等待电机反馈/传感器恢复正常的最长时间,单位秒',
+    )
     parser.add_argument(
         '--confirm-calibrated',
         action='store_true',
@@ -186,6 +192,8 @@ def main() -> int:
     last_fault_mask: int | None = None
     last_safety_message: str | None = None
     last_hold_positions = mapper.nominal_joint_targets.astype(np.float32)
+    startup_ready = not args.arm
+    ready_wait_started: float | None = None
     arm_position_margin_rad = float(safety_config.get('arm_position_limit_margin_rad', 0.05))
     run_position_margin_rad = float(safety_config.get('run_position_limit_margin_rad', arm_position_margin_rad))
     max_joint_velocity_rad_s = safety_config.get('max_joint_velocity_rad_s')
@@ -252,33 +260,100 @@ def main() -> int:
                 last_state = telemetry.robot_state
                 last_fault_mask = telemetry.fault_mask
 
-                if args.arm:
-                    arm_blockers: list[str] = []
-                    arm_blockers.extend(
-                        find_out_of_range_joints(
-                            telemetry.joint_position_rad,
-                            mapper.joint_lower,
-                            mapper.joint_upper,
-                            mapper.joint_names,
-                            margin_rad=arm_position_margin_rad,
-                        )
+            if args.arm and not startup_ready:
+                now = time.monotonic()
+                if ready_wait_started is None:
+                    ready_wait_started = now
+                    print(
+                        f'等待电机反馈恢复正常,最长 {args.ready_timeout:.1f}s;'
+                        '此阶段只发送失能保持包,不会使能电机。'
                     )
-                    if abs(telemetry.roll) > float(safety_config['max_roll_rad']):
-                        arm_blockers.append(f'roll={telemetry.roll:.3f}rad 超过首帧限制')
-                    if abs(telemetry.pitch) > float(safety_config['max_pitch_rad']):
-                        arm_blockers.append(f'pitch={telemetry.pitch:.3f}rad 超过首帧限制')
-                    if arm_blockers:
-                        print('禁止使能:当前反馈状态不在安全范围内。')
-                        for item in arm_blockers:
+
+                readiness_faults: list[str] = []
+                if telemetry.motor_fault_mask != 0:
+                    readiness_faults.append(f'检测到电机故障: {format_fault_summary(telemetry, mapper.joint_names)}')
+                if telemetry.feedback_timeout_mask != 0:
+                    readiness_faults.append(f'电机反馈超时: {format_fault_summary(telemetry, mapper.joint_names)}')
+                if (telemetry.fault_mask & TELEMETRY_FAULT_IMU_OFFLINE) != 0:
+                    readiness_faults.append('QMI8658A IMU 离线,roll/pitch 安全保护不可用')
+
+                if readiness_faults:
+                    if now - ready_wait_started >= args.ready_timeout:
+                        print('使能前等待超时,仍未恢复正常:')
+                        for item in readiness_faults:
                             print(f'  - {item}')
-                        print('请先校准 robot_config.h 的 zero_offset_rad/direction,或重新设置电机机械零位。')
+                        print('请检查电机电源、CANH/CANL、公共地、RS00 MIT 协议和 CAN ID 后再重试。')
                         sequence = send_emergency_stop(
                             link,
                             sequence=sequence,
                             hold_positions=last_hold_positions,
                             joint_count=len(mapper.joint_names),
                         )
-                        return 3
+                        return 4
+
+                    should_log_ready = (
+                        telemetry.robot_state != last_state
+                        or telemetry.fault_mask != last_fault_mask
+                        or (log_interval_s > 0 and now - last_log_time >= log_interval_s)
+                    )
+                    if should_log_ready:
+                        print_telemetry_status(telemetry, mapper.joint_names)
+                        last_log_time = now
+                        last_state = telemetry.robot_state
+                        last_fault_mask = telemetry.fault_mask
+
+                    flags = COMMAND_FLAG_DISARM | COMMAND_FLAG_HOLD
+                    if args.clear_faults and sequence < 20:
+                        flags |= COMMAND_FLAG_CLEAR_FAULTS
+                    link.send_command(
+                        CommandPacket(
+                            sequence=sequence,
+                            positions_rad=last_hold_positions,
+                            velocities_rad_s=zero_velocity,
+                            kp=kp,
+                            kd=kd,
+                            torque_ff_nm=zero_torque,
+                            flags=flags,
+                        )
+                    )
+                    sequence = (sequence + 1) & 0xFFFFFFFF
+                    time.sleep(0.05)
+                    continue
+
+                arm_blockers: list[str] = []
+                arm_blockers.extend(
+                    find_out_of_range_joints(
+                        telemetry.joint_position_rad,
+                        mapper.joint_lower,
+                        mapper.joint_upper,
+                        mapper.joint_names,
+                        margin_rad=arm_position_margin_rad,
+                    )
+                )
+                if abs(telemetry.roll) > float(safety_config['max_roll_rad']):
+                    arm_blockers.append(f'roll={telemetry.roll:.3f}rad 超过首帧限制')
+                if abs(telemetry.pitch) > float(safety_config['max_pitch_rad']):
+                    arm_blockers.append(f'pitch={telemetry.pitch:.3f}rad 超过首帧限制')
+                if arm_blockers:
+                    print('禁止使能:当前反馈状态不在安全范围内。')
+                    for item in arm_blockers:
+                        print(f'  - {item}')
+                    print('请先校准 robot_config.h 的 zero_offset_rad/direction,或重新设置电机机械零位。')
+                    sequence = send_emergency_stop(
+                        link,
+                        sequence=sequence,
+                        hold_positions=last_hold_positions,
+                        joint_count=len(mapper.joint_names),
+                    )
+                    return 3
+
+                startup_ready = True
+                limiter.reset(telemetry.joint_position_rad)
+                mapper.reset()
+                last_hold_positions = telemetry.joint_position_rad.astype(np.float32).copy()
+                start_time = time.monotonic()
+                next_tick = start_time
+                print('使能前检查通过,开始发送策略控制指令。')
 
             elapsed = time.monotonic() - start_time
             if args.max_seconds > 0 and elapsed >= args.max_seconds: