Developing Control Applications ================================ This guide covers how to develop custom control applications using **ai_nn_controller**. The framework is domain-agnostic and supports any type of network node — optical, wireless, RAN, core network, and more. Application Structure --------------------- A control application consists of: 1. **Class Definition**: Inherits from ``AicApp`` 2. **@aic_app Decorator**: Registers the app with the framework 3. **Configuration Attributes**: Define measurements and controls 4. **process() Method**: Implement your control logic Basic Template -------------- .. code-block:: python from ai_nn_controller.decorators.aic_app import aic_app from ai_nn_controller.AicApp import AicApp from ai_nn_controller.AicController import AicController @aic_app(name="MyControlApp") class MyControlApp(AicApp): """Description of your control application.""" # Unique identifier for this app aic_app_id = 100 # Processing interval in seconds control_loop_update_time = 2 # Measurements to subscribe to (node_id -> metric names) read_measurements = { 3: ["gain", "power"], 8: ["preamp_gain", "booster_gain"] } # Control functions available (node_id -> command names) control_functions = { 8: ["SET_GAIN", "SET_VOA"] } # Note: cell_ids and send_commands are auto-generated by @aic_app decorator # cell_ids will be: [3, 8] (union of read_measurements and control_functions keys) @classmethod def process(cls, measurements): """ Process incoming measurements. Args: measurements: Dict[int, List[Dict]] - node_id to measurement list """ # Your control logic here pass if __name__ == "__main__": AicController(with_api=True, verbose=True).run() Template App Generator (First-Time Walkthrough) ----------------------------------------------- If you are new to the controller, the easiest way to start is to generate a template app based on the nodes that have already registered. The generator builds a complete app folder and a focused test compose file that only includes the registered nodes plus the new template app. What the generator creates: - ``control_applications/template_app/`` with ``aic_app.py``, ``commands.py``, ``aic_app.conf``, ``Dockerfile``, and ``requirements.txt`` - ``docker-compose-test.yml`` in the project root with: - Redis + register + broker - Only the nodes that are currently registered - The generated ``template_app`` service Step 1: Start the baseline stack ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From the project root: .. code-block:: bash docker compose up -d This starts Redis, the register, the broker, and the sample nodes. The nodes must register before the generator runs so the register has the PM/control lists. Step 2: Wait for nodes to register ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You should see registration logs in the register container: .. code-block:: bash docker logs -f aic_register Look for lines like: - ``REGISTER network_node node: 3`` - ``Network Nodes sharing available PMs`` - ``Network Nodes sharing available Control Functions`` Step 3: Generate the template app + test compose ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Run the generator inside the register container: .. code-block:: bash docker exec -it aic_register python3 /register/generate_template_app.py Optional flags (shown with defaults): .. code-block:: bash docker exec -it aic_register python3 /register/generate_template_app.py \ --app-name template_app \ --app-id 100 \ --update-time 2 \ --output-root /control_applications \ --compose-output /workspace/docker-compose-test.yml The generator prints where it wrote the app folder and the compose file. It also prints the exact command to run the test compose. Step 4: Run the generated test compose ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Stop the original stack, then start the test stack: .. code-block:: bash docker compose down docker compose -f docker-compose-test.yml up --build This brings up only the registered nodes and the new ``template_app``. Configuration Attributes ------------------------ control_loop_update_time ~~~~~~~~~~~~~~~~~~~~~~~~ How often ``process()`` is called, in seconds: .. code-block:: python control_loop_update_time = 2 # Process every 2 seconds read_measurements ~~~~~~~~~~~~~~~~~ Dictionary mapping node IDs to lists of measurement names: .. code-block:: python read_measurements = { 3: ["amp1_target_gain", "amp1_gain_tilt", "amp1_target_power"], 4: ["roadm1_preamp_gain", "roadm1_booster_gain"], 8: ["roadm3_preamp_gain", "roadm3_booster_gain"] } Use ``MeasurementsHandler.ALL_MEASUREMENTS`` to receive all available metrics: .. code-block:: python from ai_nn_controller.enums import MeasurementsHandler read_measurements = { 3: [MeasurementsHandler.ALL_MEASUREMENTS] } control_functions ~~~~~~~~~~~~~~~~~ Dictionary mapping node IDs to available command names: .. code-block:: python control_functions = { 3: ["SET_GAIN"], 8: ["SET_GAIN", "SET_VOA", "SET_TILT"] } cell_ids (Auto-Generated) ~~~~~~~~~~~~~~~~~~~~~~~~~ List of node IDs to subscribe to. This is **auto-generated** by the ``@aic_app`` decorator from the union of ``read_measurements`` and ``control_functions`` keys. You do not need to define it in your app: .. code-block:: python # These definitions: read_measurements = {3: ["gain"], 4: ["power"]} control_functions = {8: ["SET_GAIN"]} # Will auto-generate: # cell_ids = [3, 4, 8] Processing Measurements ----------------------- The ``process()`` method receives measurements as a dictionary: .. code-block:: python @classmethod def process(cls, measurements): """ Args: measurements: { node_id: [ {"metric1": value, "metric2": value, ...}, # Oldest {"metric1": value, "metric2": value, ...}, # ... {"metric1": value, "metric2": value, ...} # Newest ], ... } """ # Check if we have measurements if not measurements: print("No measurements received yet") return # Get latest measurement from node 3 if 3 in measurements and measurements[3]: latest = measurements[3][-1] # Last item is newest gain = latest.get("amp1_target_gain", 0) print(f"Current gain: {gain}") # Process multiple measurements (batch processing) for node_id, measurement_list in measurements.items(): for measurement in measurement_list: # Process each measurement pass Sending Commands ---------------- Use ``add_command()`` to queue commands for sending: .. code-block:: python @classmethod def process(cls, measurements): # Get current gain latest = measurements.get(3, [{}])[-1] current_gain = latest.get("amp1_target_gain", 0) # Send command if gain is too high if current_gain > 25: cls.add_command(( "SET_GAIN", # Command name (must be registered) { "node_id": 3, "value": { "amp_type": "line", "target_gain": 20.0 } } )) Command Format ~~~~~~~~~~~~~~ Commands are tuples of ``(command_name, payload)``: .. code-block:: python ( "SET_GAIN", # Command name string { "node_id": 8, # Target node "value": { # Command parameters "amp_type": "preamp", "target_gain": 15.0 } } ) Command Validators (Optional) ----------------------------- Command validators provide guardrails for commands sent via the REST API or MCP tools. They are completely optional - if no validator is defined, commands pass through unchanged. This is useful when you want to: - Enforce safety limits (e.g., maximum gain values) - Validate parameter combinations - Reject commands based on current system state .. code-block:: python from ai_nn_controller.decorators.aic_app import aic_app from ai_nn_controller.decorators.command_validator import command_validator from ai_nn_controller.AicApp import AicApp @aic_app(name="NetworkApp2") class SafeControlApp(AicApp): control_functions = {8: ["SET_GAIN", "SET_VOA"]} # cell_ids auto-generated: [8] # Safety limits MAX_GAIN = 25.0 # dB MIN_GAIN = 0.0 # dB # IMPORTANT: @classmethod must be ABOVE @command_validator @classmethod @command_validator("SET_GAIN") def validate_set_gain(cls, params: dict) -> tuple[bool, str | None]: """ Validate SET_GAIN commands before execution. Args: params: Dict with node_id, target_gain, amp_type, etc. Returns: (True, None) if valid, (False, "error message") if invalid """ target_gain = params.get("target_gain") if target_gain is None: return False, "target_gain parameter is required" if target_gain > cls.MAX_GAIN: return False, f"target_gain {target_gain} exceeds max {cls.MAX_GAIN} dB" if target_gain < cls.MIN_GAIN: return False, f"target_gain {target_gain} is below min {cls.MIN_GAIN} dB" return True, None # SET_VOA has no validator - passes through unchanged @classmethod def process(cls, measurements): pass Validator Behavior ~~~~~~~~~~~~~~~~~~ - **No validator defined**: Command passes through unchanged (backward compatible) - **Validator returns ``(True, None)``**: Command is sent - **Validator returns ``(False, "reason")``**: Command is rejected When a command is rejected via the REST API, the response includes: .. code-block:: json { "status": "rejected", "reason": "target_gain 30.0 exceeds max 25.0 dB" } .. note:: Validators are primarily useful when the FastAPI REST API or MCP tools are being used to send manual control commands. Commands sent internally via ``add_command()`` in ``process()`` will also be validated if a validator is defined. Agent-Controlled Operations (Optional) --------------------------------------- Agent-controlled operations allow MCP/AI agents to execute logic **inside the process loop**, with access to live measurements and the app's internal state. Unlike regular MCP control tools (which bypass ``process()`` via ``send_manual_control()``), these handlers are synchronized with the process cycle. This is useful when you want to: - Give agents access to live measurements for decision-making - Let agents trigger complex logic that uses app-internal state - Keep agent actions synchronized with the control loop - Return computed results back to the agent .. code-block:: python from ai_nn_controller.decorators.aic_app import aic_app from ai_nn_controller.decorators.agent_controlled import agent_controlled from ai_nn_controller.AicApp import AicApp @aic_app(name="SmartApp") class SmartApp(AicApp): read_measurements = {8: ["preamp_gain", "signal_power"]} control_functions = {8: ["SET_GAIN"]} MAX_GAIN = 25.0 # IMPORTANT: @classmethod must be ABOVE @agent_controlled @classmethod @agent_controlled( name="optimize_gain", description="Optimize gain based on a strategy", schema={ "properties": { "node_id": {"type": "integer"}, "strategy": {"type": "string", "enum": ["max_snr", "min_power"]} }, "required": ["node_id", "strategy"] } ) def handle_optimize_gain(cls, request, measurements): """Runs inside the process loop with access to live measurements.""" node_id = request["node_id"] latest = measurements.get(node_id, [{}])[-1] or {} current_gain = latest.get("preamp_gain", 15.0) new_gain = min(current_gain + 2.0, cls.MAX_GAIN) cls.add_command(("SET_GAIN", { "node_id": node_id, "value": {"target_gain": new_gain} })) # Return value goes back to the MCP caller return {"status": "applied", "new_gain": new_gain} @classmethod def process(cls, measurements): # Normal control logic — agent requests are handled separately pass Handler Signature ~~~~~~~~~~~~~~~~~ The handler receives two arguments: - ``request`` (dict): The MCP tool arguments (matching the ``schema`` you defined) - ``measurements`` (dict): Current measurements from the process loop, same format as what ``process()`` receives The handler should return a ``dict`` that will be sent back to the MCP caller. How It Works ~~~~~~~~~~~~ .. code-block:: text Agent (MCP tool call) → push AgentRequest to queue → process() cycle picks it up → handler runs with (request, measurements) → response set → MCP returns result 1. The MCP handler creates an ``AgentRequest`` with a ``threading.Event`` 2. The request is pushed to ``cls.agent_requests`` 3. On the next process cycle, the controller drains the queue and calls the handler 4. The handler's return value is stored in the request and the event is set 5. The MCP handler wakes up and returns the result to the caller Edge Cases ~~~~~~~~~~ - **App not running**: MCP handler returns an error immediately - **Timeout**: Defaults to ``control_loop_update_time * 3 + 5`` seconds; returns a timeout error - **Handler exception**: Caught by the controller, error returned to MCP caller - **App stopped while request pending**: All pending requests are drained with an error Declaring Plugin Dependencies (Optional) ------------------------------------------- Control applications can depend on **plugins** — reusable, independently installable packages that provide typed access to external services (a time-series database, an ML model registry, a monitoring backend, etc.). Declare a dependency with ``required_plugins`` and access the loaded plugin through ``cls.plugins``: .. code-block:: python from ai_nn_controller.decorators.aic_app import aic_app from ai_nn_controller.AicApp import AicApp @aic_app(name="MonitoredApp") class MonitoredApp(AicApp): required_plugins = ["ConsolePlugin"] read_measurements = {3: ["gain", "power"]} control_functions = {} @classmethod def process(cls, measurements): console = cls.plugins["ConsolePlugin"] latest = measurements.get(3, [{}])[-1] console.log_measurement(3, latest) ``cls.plugins`` is populated by ``AicController`` at startup, after all plugin and app entry points have loaded. If a plugin listed in ``required_plugins`` isn't installed (or wasn't registered via the ``ai_nn_controller.plugin_init`` entry-point group), the controller refuses to start the app and raises ``RuntimeError`` naming the missing plugin. See :doc:`developing_plugins` for details on writing your own plugin. State Variables --------------- Store state between ``process()`` calls using class attributes: .. code-block:: python @aic_app(name="StatefulApp") class StatefulApp(AicApp): # State variables measurement_history = [] last_command_time = 0 command_counter = 0 @classmethod def process(cls, measurements): import time # Track measurement history if 3 in measurements: cls.measurement_history.extend(measurements[3]) # Keep only last 100 cls.measurement_history = cls.measurement_history[-100:] # Rate-limit commands (once per 10 seconds) current_time = time.time() if current_time - cls.last_command_time > 10: cls.add_command(...) cls.last_command_time = current_time cls.command_counter += 1 Multiple Applications --------------------- You can define multiple apps in the same file: .. code-block:: python @aic_app(name="MonitoringApp") class MonitoringApp(AicApp): aic_app_id = 1 control_loop_update_time = 2 read_measurements = {3: ["gain"]} control_functions = {} # cell_ids auto-generated: [3] @classmethod def process(cls, measurements): # Monitoring logic pass @aic_app(name="ControlApp") class ControlApp(AicApp): aic_app_id = 2 control_loop_update_time = 2 read_measurements = {8: ["preamp_gain"]} control_functions = {8: ["SET_GAIN"]} # cell_ids auto-generated: [8] @classmethod def process(cls, measurements): # Control logic pass if __name__ == "__main__": AicController(with_api=True).run() Both apps run in separate threads and share the same API server. Auto-Generated Endpoints ------------------------ The ``@aic_app`` decorator automatically creates REST endpoints: .. list-table:: Auto-Generated Endpoints :header-rows: 1 :widths: 40 15 45 * - Endpoint - Method - Description * - ``/apps/{name}/info`` - GET - App configuration * - ``/apps/{name}/state`` - GET - Current state * - ``/apps/{name}/state`` - PUT - Update state (running/paused/stopped) * - ``/apps/{name}/measurements`` - GET - Latest measurements * - ``/apps/{name}/control`` - POST - Send manual command Auto-Generated MCP Tools ------------------------ For each app, MCP tools are automatically created: - ``{AppName}_get_state`` - Get current state - ``{AppName}_set_state`` - Set state - ``{AppName}_get_measurements`` - Get measurements - ``{AppName}_{command_name}`` - For each control function - ``{AppName}_{operation_name}`` - For each ``@agent_controlled`` operation Best Practices -------------- 1. **Keep process() Fast**: Avoid blocking operations 2. **Handle Missing Data**: Check if measurements exist before accessing 3. **Use Rate Limiting**: Don't send commands every cycle 4. **Log Important Events**: Use print or logging for debugging 5. **Validate Inputs**: Check measurement values before acting Example: Complete Application ----------------------------- .. code-block:: python import time from ai_nn_controller.decorators.aic_app import aic_app from ai_nn_controller.AicApp import AicApp from ai_nn_controller.AicController import AicController @aic_app(name="GainController") class GainController(AicApp): """ Maintains amplifier gain within target range. Monitors node 3 and sends corrections to node 8. """ aic_app_id = 100 control_loop_update_time = 2 read_measurements = { 3: ["amp1_target_gain", "amp1_gain_tilt"], 8: ["roadm3_preamp_gain"] } control_functions = { 8: ["SET_GAIN"] } # Note: cell_ids and send_commands are auto-generated # cell_ids will be: [3, 8] # Configuration TARGET_GAIN = 20.0 GAIN_TOLERANCE = 2.0 MIN_COMMAND_INTERVAL = 10 # seconds # State last_correction_time = 0 @classmethod def process(cls, measurements): if not measurements: return # Get current gain from node 3 amp_data = measurements.get(3, [{}])[-1] current_gain = amp_data.get("amp1_target_gain") if current_gain is None: return print(f"[GainController] Current gain: {current_gain:.2f}") # Check if correction needed gain_error = current_gain - cls.TARGET_GAIN if abs(gain_error) > cls.GAIN_TOLERANCE: # Rate limit corrections current_time = time.time() if current_time - cls.last_correction_time > cls.MIN_COMMAND_INTERVAL: # Calculate correction new_gain = cls.TARGET_GAIN print(f"[GainController] Correcting gain: {current_gain:.2f} -> {new_gain:.2f}") cls.add_command(( "SET_GAIN", { "node_id": 8, "value": { "amp_type": "preamp", "target_gain": new_gain } } )) cls.last_correction_time = current_time if __name__ == "__main__": AicController(with_api=True, api_port=8000, verbose=True).run() Next Steps ---------- - :doc:`developing_nodes` - Build network nodes with the controlled_entity framework - :doc:`developing_plugins` - Write reusable plugins for external services - :doc:`commands` - Define custom commands - :doc:`../api/ai_nn_controller` - Full API reference - :doc:`../examples/basic_app` - More examples