Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/networked_control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Networked Controller Wrapper

This module adds a controller wrapper for sampled, delayed, bandwidth-limited
communication. It does not replace LQR, MPC, PID, or learning controllers. It
wraps them and changes which observation reaches the controller at each step.

## Theory Mapping

- `max_delay_steps` maps to the maximum allowable delay number in sampling.
- `DelayChannel.queue_depth` maps to the number of transmitted samples waiting
for delivery.
- `sampled_signal` is the source-side sampled signal.
- `held_signal` is the destination-side signal used by the wrapped controller.
- `update_error = held_signal - sampled_signal`.
- `DynamicPETCTrigger.eta` is the dynamic event-trigger memory.

## Quick Start

```python
from safe_control_gym.controllers.networked import (
DynamicPETCTrigger,
NetworkedControllerWrapper,
uniform_delay,
)

ctrl = make(config.algo, env_func, **config.algo_config)
networked_ctrl = NetworkedControllerWrapper(
ctrl,
observation_dim=env.observation_space.shape[0],
sample_period_steps=1,
max_delay_steps=3,
delay_sampler=uniform_delay(3),
trigger=DynamicPETCTrigger(error_weight=1.0, signal_weight=0.03),
)
```

Use `networked_ctrl` anywhere a safe-control-gym controller instance is accepted.

## Before/After Evaluation

Run three conditions with the same task, seed, and base controller:

1. Direct controller with fresh observations.
2. Periodic controller with fixed delayed transmissions.
3. Dynamic PETC wrapper with the same delay sampler.

Report:

- task return or tracking error
- transmission count
- event rate
- mean stale steps
- max queue depth
- safety or constraint violations when the task exposes them

## Example

```bash
python examples/networked_control/run_dynamic_petc.py --max-delay-steps 3
```

The example uses a small local double-integrator task. It is a smoke test for
the wrapper, not a reproduction of a paper simulation.

To run a configured safe-control-gym controller behind the wrapper:

```bash
python examples/networked_control/networked_lqr_experiment.py \
--algo lqr \
--task cartpole \
--overrides ./examples/lqr/config_overrides/cartpole/cartpole_stab.yaml \
./examples/lqr/config_overrides/cartpole/lqr_cartpole_stab.yaml
```

The integration point is intentionally small: create the normal controller, then
wrap it with `NetworkedControllerWrapper` before constructing `BaseExperiment`.

## Mobile Robot MPC Demo

The mobile robot MPC example demonstrates a concrete robotics use case: a
unicycle/differential-drive robot tracks a smooth path while localization
observations arrive through the dynamic PETC wrapper.

```bash
python examples/networked_control/run_mobile_robot_mpc_petc.py \
--steps 260 \
--max-delay-steps 4
```

The controller is a dependency-light finite-horizon MPC implemented by sampling
bounded control sequences and rolling out the kinematic model. It uses:

```text
state: [x, y, theta]
control: [linear velocity, angular velocity]
speed bounds: [0.0, 1.0] m/s
angular velocity bounds: [-2.2, 2.2] rad/s
horizon: 12 steps
candidate sequences: 384
```

One representative run produced:

```text
Fresh MPC RMS tracking error: 0.0513 m
PETC MPC RMS tracking error: 0.0565 m
Transmissions: 75 / 260 samples
Event rate: 28.85%
Mean stale steps: 4.22
Max queue depth: 4
```

This illustrates the intended engineering tradeoff: the wrapped MPC used about
29% of the observation transmissions while keeping the RMS path-tracking error
within 5.3 mm of the fresh-observation baseline in this scenario.

## Limits

The wrapper exposes certificate-like quantities such as update error, queue
depth, and trigger margin. It does not claim plant-level input-to-state
stability unless the user supplies compatible Lyapunov/error weights for the
task.
83 changes: 83 additions & 0 deletions examples/networked_control/networked_lqr_experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'''Run an existing safe-control-gym controller through a networked wrapper.'''

from functools import partial
import sys
from pathlib import Path

import numpy as np

PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))

from safe_control_gym.controllers.networked import DynamicPETCTrigger, NetworkedControllerWrapper, uniform_delay
from safe_control_gym.experiments.base_experiment import BaseExperiment
from safe_control_gym.utils.configuration import ConfigFactory
from safe_control_gym.utils.registration import make


def run(gui=False, n_episodes=1, n_steps=None, max_delay_steps=3, sample_period_steps=1, seed=0):
'''Run the configured controller with event-triggered delayed observations.

The configured controller can be LQR, iLQR, MPC, or another controller with
the standard safe-control-gym select_action(obs, info) method.
'''

config = ConfigFactory().merge()
env_func = partial(make, config.task, **config.task_config)
random_env = env_func(gui=False)

base_ctrl = make(config.algo, env_func, **config.algo_config)
rng = np.random.default_rng(seed)

all_network_metrics = []
n_episodes = 1 if n_episodes is None else n_episodes

for _ in range(n_episodes):
init_obs, _ = random_env.reset()
static_env = env_func(gui=gui, randomized_init=False, init_state=init_obs)
static_train_env = env_func(gui=False, randomized_init=False, init_state=init_obs)

obs_dim = int(np.asarray(init_obs).reshape(-1).size)
ctrl = NetworkedControllerWrapper(
base_ctrl,
observation_dim=obs_dim,
sample_period_steps=sample_period_steps,
max_delay_steps=max_delay_steps,
delay_sampler=uniform_delay(max_delay_steps, rng=rng),
trigger=DynamicPETCTrigger(
error_weight=1.0,
signal_weight=0.03,
eta_decay=0.02,
eta_growth=0.02,
eta_skip_gain=0.15,
eta_transmit_drop=0.3,
eta_max=0.5,
),
)
ctrl.reset_before_run(obs=init_obs, env=static_env)

experiment = BaseExperiment(env=static_env, ctrl=ctrl, train_env=static_train_env)
experiment.launch_training()
if n_steps is None:
trajs_data, _ = experiment.run_evaluation(training=True, n_episodes=1)
else:
trajs_data, _ = experiment.run_evaluation(training=True, n_steps=n_steps)

metrics = experiment.compute_metrics(trajs_data)
network_metrics = ctrl.metrics.summary()
all_network_metrics.append(network_metrics)

print('FINAL METRICS - ' + ', '.join([f'{key}: {value}' for key, value in metrics.items()]))
print('NETWORK METRICS - ' + ', '.join([f'{key}: {value}' for key, value in network_metrics.items()]))

static_env.close()
static_train_env.close()

base_ctrl.close()
random_env.close()
return all_network_metrics


if __name__ == '__main__':
run()
88 changes: 88 additions & 0 deletions examples/networked_control/run_dynamic_petc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'''Minimal dynamic PETC wrapper demonstration.

This example intentionally uses a small local plant instead of recreating a
paper example. In safe-control-gym, replace ProportionalController with an LQR,
MPC, PPO, SAC, or PID controller instance and keep the wrapper unchanged.
'''

import argparse
import sys
from pathlib import Path

import numpy as np

PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))

from safe_control_gym.controllers.networked import DynamicPETCTrigger, NetworkedControllerWrapper, uniform_delay


class ProportionalController:
'''Small controller with the same select_action shape used by safe-control-gym.'''

def select_action(self, obs, info=None):
obs = np.asarray(obs, dtype=float)
return np.array([-0.8 * obs[0] - 0.25 * obs[1]])

def reset(self):
return None

def close(self):
return None


def simulate(controller, n_steps=200, dt=0.05):
'''Run a double-integrator stabilization task.'''
x = np.array([2.0, 0.0])
trajectory = []
for step in range(n_steps):
action = np.asarray(controller.select_action(x, info={'current_step': step}), dtype=float)
u = float(np.clip(action[0], -3.0, 3.0))
x = np.array([x[0] + dt * x[1], x[1] + dt * u])
trajectory.append((step, x[0], x[1], u))
return np.asarray(trajectory)


def rms_position(trajectory):
return float(np.sqrt(np.mean(trajectory[:, 1] ** 2)))


def main():
parser = argparse.ArgumentParser()
parser.add_argument('--steps', type=int, default=200)
parser.add_argument('--max-delay-steps', type=int, default=3)
parser.add_argument('--sample-period-steps', type=int, default=1)
parser.add_argument('--seed', type=int, default=4)
args = parser.parse_args()

rng = np.random.default_rng(args.seed)
base = ProportionalController()
direct = simulate(base, n_steps=args.steps)

networked = NetworkedControllerWrapper(
ProportionalController(),
observation_dim=2,
sample_period_steps=args.sample_period_steps,
max_delay_steps=args.max_delay_steps,
delay_sampler=uniform_delay(args.max_delay_steps, rng=rng),
trigger=DynamicPETCTrigger(
error_weight=1.0,
signal_weight=0.03,
eta_decay=0.02,
eta_growth=0.02,
eta_skip_gain=0.15,
eta_transmit_drop=0.3,
eta_max=0.5,
),
)
networked.reset_before_run(obs=np.array([2.0, 0.0]))
delayed = simulate(networked, n_steps=args.steps)

print('direct_rms_position:', f'{rms_position(direct):.4f}')
print('networked_rms_position:', f'{rms_position(delayed):.4f}')
print('networked_metrics:', networked.metrics.summary())


if __name__ == '__main__':
main()
Loading