commit c359a2f6f06ce4a751f675f6b387f279c040f8db
parent 12fc01b1a11f164df1e54528b54e785ba81f7a94
Author: maydayv7 <maydayv7@gmail.com>
Date: Mon, 30 Mar 2026 00:08:21 +0530
Add File
Diffstat:
1 file changed, 364 insertions(+), 0 deletions(-)
diff --git a/simulator/research_simulator.ipynb b/simulator/research_simulator.ipynb
@@ -0,0 +1,364 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "header"
+ },
+ "source": [
+ "# Research Grade Hydroponics Simulator\n",
+ "\n",
+ "This notebook provides a comprehensive environment for simulating and managing a hydroponic crop system. It integrates theoretical physics, deep learning, and real-time synchronization with cloud services.\n",
+ "\n",
+ "**Key Features:**\n",
+ "* **Physics-Informed Core**: Combines first-principles differential equations with a Physics-Informed Neural Network (PINN) for \"biological chaos\" modeling.\n",
+ "* **Reinforcement Learning**: Built-in Gymnasium environment for training optimal control agents using Proximal Policy Optimization (PPO).\n",
+ "* **Azure Digital Twins**: Real-time telemetry synchronization with Azure-hosted Digital Twins.\n",
+ "* **Research API**: Exposes a FastAPI server with ngrok tunneling for remote telemetry and control."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "dependencies"
+ },
+ "source": [
+ "## Setup & Dependencies"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "pip_installs"
+ },
+ "outputs": [],
+ "source": [
+ "!pip install torch azure-digitaltwins-core azure-identity Pillow numpy fastapi uvicorn nest_asyncio google-genai gymnasium stable-baselines3 pyngrok -q\n",
+ "!pip install diffusers transformers accelerate torchvision -q"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "imports"
+ },
+ "outputs": [],
+ "source": [
+ "import base64\n",
+ "import time\n",
+ "import os\n",
+ "import json\n",
+ "import threading\n",
+ "import uvicorn\n",
+ "import nest_asyncio\n",
+ "import numpy as np\n",
+ "import torch\n",
+ "import torch.nn as nn\n",
+ "import gymnasium as gym\n",
+ "from gymnasium import spaces\n",
+ "from io import BytesIO\n",
+ "from collections import deque\n",
+ "from dataclasses import dataclass, asdict\n",
+ "from fastapi import FastAPI\n",
+ "from pydantic import BaseModel\n",
+ "from PIL import Image\n",
+ "from stable_baselines3 import PPO\n",
+ "from azure.identity import DeviceCodeCredential\n",
+ "from azure.digitaltwins.core import DigitalTwinsClient\n",
+ "from pyngrok import ngrok\n",
+ "\n",
+ "nest_asyncio.apply()\n",
+ "\n",
+ "# Configuration\n",
+ "MODEL_PATH = \"models/PPO/lettuce_brain_v1.zip\"\n",
+ "HISTORY_LEN = 20\n",
+ "ADT_URL = \"simulator.api.krc.digitaltwins.azure.net\"\n",
+ "TWIN_ID = \"HydrophonicTank\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "physics_header"
+ },
+ "source": [
+ "## 1. Physics-Informed Architecture\n",
+ "\n",
+ "Defining the `ResidualPhysicsNet` which captures non-linear biological effects that standard textbook equations often miss."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "physics_net"
+ },
+ "outputs": [],
+ "source": [
+ "class ResidualPhysicsNet(nn.Module):\n",
+ " def __init__(self, state_dim=7, action_dim=4):\n",
+ " super().__init__()\n",
+ " self.net = nn.Sequential(\n",
+ " nn.Linear(state_dim + action_dim, 64),\n",
+ " nn.Tanh(),\n",
+ " nn.Linear(64, 64),\n",
+ " nn.ReLU(),\n",
+ " nn.Linear(64, state_dim)\n",
+ " )\n",
+ " with torch.no_grad():\n",
+ " self.net[-1].weight.mul_(0.01)\n",
+ "\n",
+ " def forward(self, state, action):\n",
+ " x = torch.cat([state, action], dim=-1)\n",
+ " return self.net(x)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "rl_header"
+ },
+ "source": [
+ "## 2. Gymnasium Environment\n",
+ "\n",
+ "Standardized RL interface for training control agents."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "hydro_env"
+ },
+ "outputs": [],
+ "source": [
+ "class HydroponicsEnv(gym.Env):\n",
+ " def __init__(self):\n",
+ " super().__init__()\n",
+ " self.high_obs = np.array([14.0, 5.0, 40.0, 50.0, 100.0, 5.0, 5000.0], dtype=np.float32)\n",
+ " self.low_obs = np.array([0.0, 0.0, 5.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32)\n",
+ " self.observation_space = spaces.Box(low=self.low_obs, high=self.high_obs, dtype=np.float32)\n",
+ " self.action_space = spaces.Box(low=0.0, high=1.0, shape=(4,), dtype=np.float32)\n",
+ " \n",
+ " self.state = None\n",
+ " self.residual_model = ResidualPhysicsNet(7, 4)\n",
+ "\n",
+ " def reset(self, seed=None, options=None):\n",
+ " super().reset(seed=seed)\n",
+ " self.state = np.array([\n",
+ " 6.0 + np.random.uniform(-0.2, 0.2), # pH\n",
+ " 1.5 + np.random.uniform(-0.1, 0.1), # EC\n",
+ " 20.0, 24.0, 60.0, 1.0, 10.0\n",
+ " ], dtype=np.float32)\n",
+ " return self.state, {}\n",
+ "\n",
+ " def _physics_prior(self, state, action):\n",
+ " ph, ec, w_temp, a_temp, hum, vpd, biomass = state\n",
+ " acid, base, nutes, fan = action\n",
+ " d_ph = (base * 0.5) - (acid * 0.5) + (0.01 * biomass / 1000)\n",
+ " uptake = 0.05 * biomass * vpd\n",
+ " d_ec = (nutes * 0.2) - (uptake / 100.0)\n",
+ " stress = np.abs(vpd - 1.0)\n",
+ " growth_rate = 0.1 * (1.0 - min(stress, 1.0))\n",
+ " d_biomass = biomass * growth_rate\n",
+ " d_temp = -1.0 * fan + 0.1 \n",
+ " d_hum = -5.0 * fan + 2.0\n",
+ " new_vpd = 0.61 * np.exp((17.27 * a_temp)/(a_temp+237.3)) * (1 - hum/100)\n",
+ " d_vpd = new_vpd - vpd\n",
+ " return np.array([d_ph, d_ec, 0, d_temp, d_hum, d_vpd, d_biomass], dtype=np.float32)\n",
+ "\n",
+ " def step(self, action):\n",
+ " action = np.array(action, dtype=np.float32)\n",
+ " d_physics = self._physics_prior(self.state, action)\n",
+ " with torch.no_grad():\n",
+ " st_t = torch.tensor(self.state, dtype=torch.float32)\n",
+ " at_t = torch.tensor(action, dtype=torch.float32)\n",
+ " d_residual = self.residual_model(st_t, at_t).numpy()\n",
+ " \n",
+ " self.state += d_physics + (d_residual * 0.1)\n",
+ " self.state = np.clip(self.state, self.low_obs, self.high_obs)\n",
+ " ph, ec, _, _, _, _, biomass = self.state\n",
+ " reward = -abs(ph - 6.0) * 10.0 - abs(ec - 1.5) * 5.0 + biomass * 0.1\n",
+ " terminated = bool(ph < 4.0 or ph > 8.0)\n",
+ " return self.state, float(reward), terminated, False, {}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "training_header"
+ },
+ "source": [
+ "## 3. Agent Training (PPO)\n",
+ "\n",
+ "Train the agent using Stable Baselines3. This simulates the optimization of resource usage for growth."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "training_loop"
+ },
+ "outputs": [],
+ "source": [
+ "if __name__ == \"__main__\":\n",
+ " env = HydroponicsEnv()\n",
+ " os.makedirs(\"models/PPO\", exist_ok=True)\n",
+ " os.makedirs(\"logs\", exist_ok=True)\n",
+ "\n",
+ " model = PPO(\"MlpPolicy\", env, verbose=1, tensorboard_log=\"logs\")\n",
+ " print(\"🤖 Training Agent...\")\n",
+ " model.learn(total_timesteps=10000)\n",
+ " \n",
+ " model.save(MODEL_PATH)\n",
+ " print(f\"💾 Model saved to: {MODEL_PATH}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "azure_header"
+ },
+ "source": [
+ "## 4. Cloud Integration (Azure Digital Twins)\n",
+ "\n",
+ "Logic for synchronizing local simulator state with a cloud-hosted Digital Twin."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "azure_sync"
+ },
+ "outputs": [],
+ "source": [
+ "try:\n",
+ " credential = DeviceCodeCredential()\n",
+ " client = DigitalTwinsClient(ADT_URL, credential)\n",
+ " \n",
+ " def sync_to_azure(state):\n",
+ " ph, ec, water_temp, air_temp, humidity, vpd, biomass = state\n",
+ " payload = {\n",
+ " \"ph\": float(ph),\n",
+ " \"ec\": float(ec),\n",
+ " \"water_temp\": float(water_temp),\n",
+ " \"air_temp\": float(air_temp),\n",
+ " \"humidity\": float(humidity),\n",
+ " \"vpd\": float(vpd),\n",
+ " \"biomass_g\": float(biomass)\n",
+ " }\n",
+ " client.publish_telemetry(TWIN_ID, payload)\n",
+ "\n",
+ " def initialize_twin_state():\n",
+ " initial_patch = [\n",
+ " {\"op\": \"add\", \"path\": \"/ph\", \"value\": 6.0},\n",
+ " {\"op\": \"add\", \"path\": \"/ec\", \"value\": 1.5},\n",
+ " {\"op\": \"add\", \"path\": \"/biomass_g\", \"value\": 10.0}\n",
+ " ]\n",
+ " client.update_digital_twin(TWIN_ID, initial_patch)\n",
+ " print(\"✅ Twin initialized!\")\n",
+ "except NameError:\n",
+ " print(\"⚠️ Azure packages or identity missing. Skipping initialization.\")\n",
+ "except Exception as e:\n",
+ " print(f\"⚠️ Azure error: {e}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "api_header"
+ },
+ "source": [
+ "## 5. Research Simulator API & Ngrok Gateway\n",
+ "\n",
+ "The simulation engine and FastAPI server provide a programmatic interface for remote monitoring and action."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "api_server"
+ },
+ "outputs": [],
+ "source": [
+ "@dataclass\n",
+ "class FarmStateData:\n",
+ " ph: float; ec: float; water_temp: float; air_temp: float\n",
+ " humidity: float; vpd: float; biomass_g: float; tank_volume_l: float\n",
+ "\n",
+ "class FarmAction(BaseModel):\n",
+ " acid_dosage_ml: float = 0.0\n",
+ " base_dosage_ml: float = 0.0\n",
+ " nutrient_dosage_ml: float = 0.0\n",
+ " fan_speed_pct: float = 0.0\n",
+ " debug_force_ph: float | None = None\n",
+ "\n",
+ "class DigitalTwin:\n",
+ " def __init__(self):\n",
+ " self.state = np.array([6.0, 1.5, 20.0, 24.0, 60.0, 1.0, 10.0], dtype=np.float32)\n",
+ " self.plant_health = 100.0\n",
+ " self.residual_model = ResidualPhysicsNet(7, 4)\n",
+ " self.history = {k: deque([v]*5, maxlen=HISTORY_LEN) for k,v in zip([\"ph\", \"ec\", \"water_temp\", \"air_temp\", \"humidity\", \"vpd\"], self.state[:6])}\n",
+ "\n",
+ " def step(self, action: FarmAction = None):\n",
+ " if action is None: action = FarmAction()\n",
+ " u = np.array([action.acid_dosage_ml/10.0, action.base_dosage_ml/10.0, action.nutrient_dosage_ml/20.0, action.fan_speed_pct/100.0], dtype=np.float32)\n",
+ " \n",
+ " ph, ec, wt, at, hum, vpd, bio = self.state\n",
+ " d_physics = np.array([(u[1]-u[0])*0.5 + 0.001*bio, u[2]*0.2 - (0.02*bio*vpd)/100.0, 0, 0.1 - u[3]*1.5, 1.0 - u[3]*5.0, 0, 0.1*bio*(1.0-abs(vpd-1.0))], dtype=np.float32)\n",
+ " \n",
+ " with torch.no_grad():\n",
+ " nn_delta = self.residual_model(torch.tensor(self.state), torch.tensor(u)).numpy()\n",
+ " \n",
+ " self.state += d_physics + (nn_delta * 0.05)\n",
+ " self.state[0] = action.debug_force_ph if action.debug_force_ph else self.state[0]\n",
+ " self.state[3:5] = np.clip(self.state[3:5], [0, 0], [50, 100])\n",
+ " \n",
+ " for i, k in enumerate(self.history.keys()): self.history[k].append(float(self.state[i]))\n",
+ " return Image.new('RGB', (512, 512), (50, 50, 50))\n",
+ "\n",
+ "app = FastAPI()\n",
+ "sim = DigitalTwin()\n",
+ "\n",
+ "@app.get(\"/simulation/state\")\n",
+ "async def get_state():\n",
+ " img = sim.step()\n",
+ " buf = BytesIO(); img.save(buf, format=\"PNG\")\n",
+ " return {\"sensor_window\": {k: list(v) for k,v in sim.history.items()}, \"metadata\": {\"health\": round(float(sim.plant_health), 1), \"biomass\": round(float(sim.state[6]), 2)}, \"image\": base64.b64encode(buf.getvalue()).decode(\"utf-8\")}\n",
+ "\n",
+ "@app.post(\"/simulation/action\")\n",
+ "async def handle_action(action: FarmAction):\n",
+ " sim.step(action)\n",
+ " try: sync_to_azure(sim.state)\n",
+ " except: pass\n",
+ " return {\"status\": \"success\", \"ph\": float(sim.state[0])}\n",
+ "\n",
+ "def run_api():\n",
+ " print(\"🚀 Research Simulator API Online (Port 3001)\")\n",
+ " uvicorn.run(app, host=\"0.0.0.0\", port=3001, log_level=\"error\")\n",
+ "\n",
+ "threading.Thread(target=run_api, daemon=True).start()\n",
+ "\n",
+ "# Exposed via Ngrok\n",
+ "try:\n",
+ " ngrok.set_auth_token(NGROK_TOKEN)\n",
+ " public_url = ngrok.connect(3001).public_url\n",
+ " print(f\"🌍 Tunnel Ready: {public_url}\")\n",
+ "except Exception as e: print(f\"⚠️ Ngrok failed: {e}\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}