shankerram3 commited on
Commit
62b53b4
·
verified ·
1 Parent(s): 30d3e8d

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -1,13 +1,32 @@
1
- ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
2
- FROM ${BASE_IMAGE}
3
-
4
- COPY src/openenv/ /app/src/openenv/
5
- COPY envs/wildfire_env/ /app/envs/wildfire_env/
6
- COPY README.md /app/README.md
7
-
8
- ENV ENABLE_WEB_INTERFACE=true
9
-
10
- HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
11
- CMD curl -f http://localhost:8000/health || exit 1
12
-
13
- CMD ["sh", "-lc", "python -m uvicorn wildfire_env.server.app:app --host 0.0.0.0 --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips='*'"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app/env
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ git \
10
+ curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy environment files
14
+ COPY . .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -e .
18
+
19
+ # Expose port
20
+ EXPOSE 8000
21
+
22
+ # Set environment variables
23
+ ENV PYTHONUNBUFFERED=1
24
+ ENV ENABLE_WEB_INTERFACE=true
25
+
26
+ # Health check
27
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
28
+ CMD curl -f http://localhost:8000/health || exit 1
29
+
30
+ # Run the server
31
+ # Use shell form to allow PORT variable expansion for Hugging Face Spaces
32
+ CMD python -m uvicorn wildfire_env.server.app:app --host 0.0.0.0 --port ${PORT:-8000} --proxy-headers --forwarded-allow-ips '*'
README.md CHANGED
@@ -1,1091 +1,1091 @@
1
- ---
2
- title: Wildfire Environment Server
3
- emoji: 🔥
4
- colorFrom: red
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- app_port: 8000
9
- base_path: /web
10
- tags:
11
- - openenv
12
- - reinforcement-learning
13
- - wildfire
14
- - simulation
15
- ---
16
-
17
- # 🌲 Wildfire Environment
18
-
19
- Autonomous wildfire-control simulation for reinforcement-learning agents, built on the [OpenEnv](https://github.com/openenv) framework.
20
- Agents must contain spreading fires using **water**, **firebreaks**, and **timing strategies** under changing **wind** and **humidity** conditions.
21
-
22
- [![Docker](https://img.shields.io/badge/docker-ready-blue)](https://hub.docker.com/)
23
- [![Python](https://img.shields.io/badge/python-3.10+-green)](https://www.python.org/)
24
- [![FastAPI](https://img.shields.io/badge/backend-fastapi-teal)](https://fastapi.tiangolo.com/)
25
- [![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
26
-
27
- ---
28
-
29
- ## 📋 Table of Contents
30
-
31
- 1. [Why Wildfire Simulation?](#-why-wildfire-simulation)
32
- 2. [Quick Start](#-quick-start)
33
- 3. [Environment Overview](#-environment-overview)
34
- 4. [Grid Format & Encoding](#-grid-format--encoding)
35
- 5. [Actions](#-actions)
36
- 6. [Observations](#-observations)
37
- 7. [Reward Structure](#-reward-structure)
38
- 8. [Fire Spread Mechanics](#-fire-spread-mechanics)
39
- 9. [Configuration](#-configuration)
40
- 10. [Installation & Usage](#-installation--usage)
41
- 11. [API Reference](#-api-reference)
42
- 12. [Examples](#-examples)
43
- 13. [Web Interface](#-web-interface)
44
- 14. [Troubleshooting](#-troubleshooting)
45
- 15. [References](#-references)
46
-
47
- ---
48
-
49
- ## 🔥 Why Wildfire Simulation?
50
-
51
- Wildland fires are intensifying globally due to climate change — increasing the urgency for **AI-assisted decision-making**.
52
- This environment explores how intelligent systems can **control** fire spread in real time, under limited resources.
53
-
54
- ### Research Motivation
55
- ✅ Based on real wildfire science inspired by:
56
- - **Rothermel Surface Fire Spread Model** (USDA Forest Service)
57
- - **MITRE Fireline's SimFire** — physics-informed RL fire simulator
58
- - **SimHarness** — RL evaluation for disaster response
59
-
60
- ### Application Goals
61
- | Research Theme | Role in This Environment |
62
- |---|---|
63
- | Resource-Constrained Planning | Finite water + firebreak budgets |
64
- | Fire Spread + Containment Strategy | Directional wind & moisture effects |
65
- | Disaster Response RL | Safety-focused reward design |
66
- | LLM Agents for Control Tasks | Text-based action decision making |
67
-
68
- This makes WildfireEnv a **fast, controllable**, and **open benchmark** for applied RL and LLM reasoning.
69
-
70
- ---
71
-
72
- ## 🚀 Quick Start
73
-
74
- ### Using Docker (Recommended)
75
-
76
- ```bash
77
- # Build base image (first time only)
78
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
79
-
80
- # Build wildfire environment
81
- docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
82
-
83
- # Run container
84
- docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
85
- ```
86
-
87
- **Note:** The web interface can be enabled with `ENABLE_WEB_INTERFACE=true`. Access it at `http://localhost:8000/web` when enabled.
88
-
89
- ### Basic Python Client
90
-
91
- ```python
92
- from envs.wildfire_env import WildfireEnv, WildfireAction
93
-
94
- # Connect to running server
95
- env = WildfireEnv(base_url="http://localhost:8000")
96
-
97
- # Reset environment
98
- result = env.reset()
99
- obs = result.observation
100
- print(f"Grid: {obs.width}x{obs.height}, Fires: {obs.burning_count}, Water: {obs.remaining_water}")
101
-
102
- # Take action (water a burning cell)
103
- result = env.step(WildfireAction(action="water", x=10, y=15))
104
- print(f"Reward: {result.reward:.2f}, Burning: {result.observation.burning_count}")
105
-
106
- # Create firebreak
107
- result = env.step(WildfireAction(action="break", x=12, y=15))
108
-
109
- # Wait (fire spreads)
110
- result = env.step(WildfireAction(action="wait"))
111
-
112
- env.close()
113
- ```
114
-
115
- ---
116
-
117
- ## 🔥 Environment Overview
118
-
119
- This environment models **forest-fire dynamics** influenced by:
120
- - **Wind direction** (8 directions + calm) - accelerates fire spread in wind direction
121
- - **Humidity** (0.0-1.0) - suppresses ignition probability
122
- - **Fuel type and spread rate** - vegetation burns and spreads to neighbors
123
- - **Limited resources** (water units, break materials) - strategic resource management
124
- - **Time pressure** (each step costs small reward penalty)
125
-
126
- The goal is to **minimize fire spread** and **total burned area** while using resources efficiently.
127
-
128
- ### Episode Termination
129
-
130
- An episode ends when:
131
- - **All fires are extinguished** (`burning_count == 0`) - **Success!**
132
- - **Maximum steps reached** (`step_count >= max_steps`) - Time limit exceeded
133
-
134
- ---
135
-
136
- ## 🧱 Grid Format & Encoding
137
-
138
- ### Grid Structure
139
-
140
- The grid is returned as a **flat 1D array** in the observation. To access cell at position `(x, y)`:
141
-
142
- ```python
143
- index = y * width + x
144
- cell_value = observation.grid[index]
145
- ```
146
-
147
- **Example:** For a 32×32 grid, cell at (10, 15):
148
- ```python
149
- index = 15 * 32 + 10 # = 490
150
- cell_value = observation.grid[490]
151
- ```
152
-
153
- ### Cell Encoding
154
-
155
- | Code | Meaning | Color (Visualization) | Behavior |
156
- |------|----------------|-----------------------|----------|
157
- | `0` | Ash (burned) | Black ⚫ | Burned out, cannot reignite |
158
- | `1` | Fuel | Green 🟩 | Healthy vegetation, can ignite |
159
- | `2` | Burning | Red 🔥 | Currently on fire, spreads to neighbors |
160
- | `3` | Firebreak | Brown 🟫 | Barrier, fire cannot cross |
161
- | `4` | Water/Damp | Blue 🔵 | Dampened, immune to ignition temporarily |
162
-
163
- ### Grid Visualization Example
164
-
165
- ```python
166
- import numpy as np
167
-
168
- obs = env.reset().observation
169
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
170
-
171
- # Now grid_2d[y][x] gives the cell value at position (x, y)
172
- print(grid_2d[15][10]) # Cell at x=10, y=15
173
- ```
174
-
175
- ---
176
-
177
- ## 🎮 Actions
178
-
179
- ### Action Types
180
-
181
- #### 1. `water` - Apply Water
182
- **Extinguishes burning cells and dampens fuel to prevent ignition.**
183
-
184
- ```python
185
- WildfireAction(action="water", x=10, y=15)
186
- ```
187
-
188
- **Effects:**
189
- - **Burning cell (2)**: Extinguishes → becomes Water/Damp (4), gives **+0.25 reward**
190
- - **Fuel cell (1)**: Dampens → becomes Water/Damp (4), gives **-0.10 reward** (preventive, slight penalty)
191
- - **Water/Damp cell (4)**: Redundant watering, gives **-0.05 reward**
192
- - **Ash/Break (0, 3)**: Wasteful, gives **-0.05 reward**
193
-
194
- **Resource Cost:** 1 water unit per action
195
- **Requires:** `remaining_water > 0` and valid coordinates
196
-
197
- **Best Use:** Extinguish active fires before they spread
198
-
199
- ---
200
-
201
- #### 2. `break` - Create Firebreak
202
- **Builds a fire-resistant barrier that stops fire spread.**
203
-
204
- ```python
205
- WildfireAction(action="break", x=12, y=15)
206
- ```
207
-
208
- **Effects:**
209
- - **Fuel/Water cell (1, 4)**: Creates firebreak → becomes Firebreak (3), gives **+0.15 reward**
210
- - **Burning cell (2)**: Extinguishes → becomes Firebreak (3), gives **-0.02 reward** (less effective than water)
211
- - **Firebreak (3)**: Redundant, gives **-0.01 reward**
212
- - **Ash (0)**: Wasteful, gives **-0.02 reward**
213
-
214
- **Resource Cost:** 1 firebreak material per action
215
- **Requires:** `remaining_breaks > 0` and valid coordinates
216
-
217
- **Best Use:** Create barriers ahead of fire front to contain spread
218
-
219
- ---
220
-
221
- #### 3. `wait` - Do Nothing
222
- **Let natural fire dynamics occur (fire spreads).**
223
-
224
- ```python
225
- WildfireAction(action="wait")
226
- ```
227
-
228
- **Effects:**
229
- - No resource cost
230
- - No coordinate required
231
- - Fire spreads naturally to neighboring cells
232
- - Small time penalty (-0.01 reward per step)
233
-
234
- **Best Use:** When fire is contained, waiting for it to burn out
235
-
236
- ---
237
-
238
- ### Invalid Actions
239
-
240
- Actions that fail (give **-0.05 reward**):
241
- - Invalid coordinates (out of bounds)
242
- - Using water when `remaining_water == 0`
243
- - Using break when `remaining_breaks == 0`
244
- - Missing required coordinates for water/break actions
245
-
246
- ---
247
-
248
- ## 👁️ Observations
249
-
250
- ### `WildfireObservation`
251
-
252
- Returned after every `reset()` or `step()`:
253
-
254
- ```python
255
- @dataclass
256
- class WildfireObservation(Observation):
257
- grid: List[int] # Flat array: [1,1,2,1,...] length = width × height
258
- width: int # Grid width (default: 32)
259
- height: int # Grid height (default: 32)
260
- step: int # Current step number (0 at reset)
261
- wind_dir: str # "N", "NE", "E", "SE", "S", "SW", "W", "NW", "CALM"
262
- humidity: float # [0.0, 1.0] - higher = less fire spread
263
- burning_count: int # Number of cells currently on fire
264
- burned_count: int # Total number of ash cells (cumulative)
265
- remaining_water: int # Water units left
266
- remaining_breaks: int # Firebreak materials left
267
- reward_hint: float # Shaping reward (for debugging)
268
- done: bool # Episode ended?
269
- reward: float # Step reward
270
- ```
271
-
272
- ### Example Observation
273
-
274
- ```python
275
- result = env.reset()
276
- obs = result.observation
277
-
278
- print(f"Step: {obs.step}") # 0
279
- print(f"Grid size: {obs.width}x{obs.height}") # 32x32
280
- print(f"Grid cells: {len(obs.grid)}") # 1024
281
- print(f"Active fires: {obs.burning_count}") # 2
282
- print(f"Wind: {obs.wind_dir}") # "NE"
283
- print(f"Humidity: {obs.humidity:.2f}") # 0.24
284
- print(f"Water left: {obs.remaining_water}") # 8
285
- print(f"Breaks left: {obs.remaining_breaks}") # 50
286
- ```
287
-
288
- ---
289
-
290
- ## 💰 Reward Structure
291
-
292
- ### Step Rewards
293
-
294
- | Action | Condition | Reward |
295
- |--------|-----------|--------|
296
- | **Water burning cell** | Extinguishes fire | **+0.25** |
297
- | **Water fuel cell** | Preventive dampening | **-0.10** |
298
- | **Create firebreak** | From fuel/water | **+0.15** |
299
- | **Fire spreads** | Each new burning cell | **-0.15 per cell** |
300
- | **Fire shrinks** | Each extinguished cell | **+0.10 per cell** |
301
- | **New burned area** | Each cell turns to ash | **-0.05 per cell** |
302
- | **Time penalty** | Every step | **-0.01** |
303
- | **Invalid action** | Out of bounds, no resources | **-0.05** |
304
- | **Redundant action** | Watering already damp cell | **-0.05** |
305
-
306
- ### Episode End Bonuses
307
-
308
- When episode terminates (`done == True`):
309
-
310
- - **Fire contained** (`burning_count == 0`):
311
- - **+0.5** base bonus
312
- - **+0.5 × saved_ratio** bonus (proportion of cells not burned)
313
-
314
- - **Fallback reward**:
315
- - **+0.2 × (1.0 - burned_ratio)** bonus
316
-
317
- **Example:** Perfect containment (no burned cells):
318
- ```python
319
- Reward = +0.5 + 0.5 × 1.0 = +1.0
320
- ```
321
-
322
- ### Reward Interpretation
323
-
324
- - **Positive rewards**: Good containment actions, extinguishing fires
325
- - **Negative rewards**: Fire spread, resource waste, time penalty
326
- - **Goal**: Maximize cumulative reward = minimize fire damage
327
-
328
- ---
329
-
330
- ## 🌪️ Fire Spread Mechanics
331
-
332
- ### Spread Model
333
-
334
- Fire spreads using an **8-directional neighbor model**:
335
-
336
- 1. **Burning cells persist** for `burn_lifetime = 3` ticks before turning to ash
337
- 2. Each burning cell can ignite **neighboring fuel cells** (8 directions)
338
- 3. Spread probability depends on:
339
- - **Base ignition probability**: `0.30` (30% chance)
340
- - **Humidity factor**: `(1.0 - humidity)` - higher humidity = less spread
341
- - **Wind multiplier**:
342
- - **+2.0x** in wind direction
343
- - **+0.5x** against wind
344
- - **+1.0x** perpendicular
345
- - **Diagonal factor**: `0.6x` for diagonal neighbors (slower spread)
346
-
347
- 4. **Water/Damp cells (4)** are **immune** to ignition while damp
348
- 5. **Firebreaks (3)** **cannot** be crossed by fire
349
- 6. **Ash cells (0)** cannot reignite
350
-
351
- ### Wind Effects
352
-
353
- | Wind Direction | Effect on Fire Spread |
354
- |----------------|----------------------|
355
- | **In wind direction** | 2× faster ignition probability |
356
- | **Against wind** | 0.5× slower ignition probability |
357
- | **Perpendicular** | Normal (1×) ignition probability |
358
- | **CALM** | No directional bias |
359
-
360
- ### Water Dampening Duration
361
-
362
- Watered cells (4) remain damp for **6 ticks** before reverting to fuel (1).
363
-
364
- ### Example Fire Spread
365
-
366
- ```
367
- Step 0: Step 1: Step 2:
368
- 🟩🟩🟩 🟩🟥🟩 🟫🟥🟫
369
- 🟩🟥🟩 → 🟥🟥🟥 → 🟥🟥🟥 (Wind: E, spreading east)
370
- 🟩🟩🟩 🟩🟥🟩 🟫🟥🟫
371
- ```
372
-
373
- ---
374
-
375
- ## ⚙️ Configuration
376
-
377
- ### Environment Variables
378
-
379
- Set these **before starting the server**:
380
-
381
- | Variable | Description | Default | Range |
382
- |-----------|-------------|---------|-------|
383
- | `WILDFIRE_WIDTH` | Grid width in cells | `32` | 8-128 |
384
- | `WILDFIRE_HEIGHT` | Grid height in cells | `32` | 8-128 |
385
- | `WILDFIRE_HUMIDITY` | Initial humidity level | `0.25` | 0.0-1.0 |
386
- | `WILDFIRE_WIND` | Wind direction (fixed) | Random | `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW`, `CALM` |
387
- | `WILDFIRE_SEED` | Random seed | `3407` | Any integer |
388
- | `WILDFIRE_MAX_STEPS` | Max steps per episode | `128` | 10-1000 |
389
- | `WILDFIRE_WATER_CAPACITY` | Initial water units | `8` | 1-100 |
390
- | `WILDFIRE_BREAK_CAPACITY` | Initial firebreak materials | `50` | 1-200 |
391
-
392
- ### Python API Configuration
393
-
394
- ```python
395
- from envs.wildfire_env.server.wildfire_environment import WildfireEnvironment
396
-
397
- env = WildfireEnvironment(
398
- width=64,
399
- height=64,
400
- humidity=0.3,
401
- init_sources=3, # Number of initial fires
402
- max_steps=200,
403
- water_capacity=10,
404
- break_capacity=75,
405
- seed=42
406
- )
407
- ```
408
-
409
- ### Docker Configuration
410
-
411
- ```bash
412
- docker run -p 8000:8000 \
413
- -e WILDFIRE_WIDTH=64 \
414
- -e WILDFIRE_HEIGHT=64 \
415
- -e WILDFIRE_HUMIDITY=0.4 \
416
- -e WILDFIRE_WIND=N \
417
- -e WILDFIRE_WATER_CAPACITY=12 \
418
- wildfire-env:latest
419
- ```
420
-
421
- ### Custom Configuration
422
-
423
- ```bash
424
- # Build and run with custom configuration
425
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
426
- docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
427
- docker run -p 8000:8000 \
428
- -e ENABLE_WEB_INTERFACE=true \
429
- -e WILDFIRE_WIDTH=64 \
430
- -e WILDFIRE_HEIGHT=64 \
431
- -e WILDFIRE_HUMIDITY=0.5 \
432
- wildfire-env:latest
433
- ```
434
-
435
- ---
436
-
437
- ## 🚀 Installation & Usage
438
-
439
- ### Option 1: Docker (Recommended)
440
-
441
- **Manual setup:**
442
- ```bash
443
- # Build base image (first time only)
444
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
445
-
446
- # Build wildfire environment
447
- docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
448
-
449
- # Run container
450
- docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
451
- ```
452
-
453
- This approach:
454
- - Builds the base image if needed
455
- - Rebuilds the wildfire image
456
- - Starts the container
457
- - Shows logs in real-time
458
-
459
- **Alternative: Using build_docker.sh script:**
460
- ```bash
461
- # Build base image (first time only)
462
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
463
-
464
- # Build wildfire environment using the script
465
- cd src/envs/wildfire_env/server
466
- ./build_docker.sh
467
-
468
- # Run container
469
- docker run -d -p 8000:8000 --name wildfire-env-container wildfire-env:latest
470
-
471
- # View logs
472
- docker logs -f wildfire-env-container
473
-
474
- # Stop container
475
- docker stop wildfire-env-container
476
-
477
- # Remove container
478
- docker rm wildfire-env-container
479
- ```
480
-
481
- ### Option 2: Local Development (No Docker)
482
-
483
- **Requirements:**
484
- ```bash
485
- pip install fastapi uvicorn numpy matplotlib requests
486
- ```
487
-
488
- **Run server:**
489
- ```bash
490
- # From OpenEnv root directory
491
- python -m envs.wildfire_env.server.app
492
- ```
493
-
494
- **Or with environment variables:**
495
- ```bash
496
- WILDFIRE_WIDTH=64 WILDFIRE_HUMIDITY=0.3 python -m envs.wildfire_env.server.app
497
- ```
498
-
499
- ---
500
-
501
- ## 📚 API Reference
502
-
503
- ### Client Class
504
-
505
- ```python
506
- from envs.wildfire_env import WildfireEnv
507
-
508
- # Connect to existing server
509
- env = WildfireEnv(base_url="http://localhost:8000")
510
-
511
- # Or create from Docker image
512
- env = WildfireEnv.from_docker_image("wildfire-env:latest")
513
- ```
514
-
515
- ### Methods
516
-
517
- #### `reset() -> StepResult[WildfireObservation]`
518
-
519
- Resets the environment to initial state.
520
-
521
- ```python
522
- result = env.reset()
523
- obs = result.observation
524
- print(f"New episode: {obs.step == 0}")
525
- ```
526
-
527
- #### `step(action: WildfireAction) -> StepResult[WildfireObservation]`
528
-
529
- Takes an action and returns new observation.
530
-
531
- ```python
532
- action = WildfireAction(action="water", x=10, y=15)
533
- result = env.step(action)
534
- print(f"Reward: {result.reward}, Done: {result.done}")
535
- ```
536
-
537
- #### `state -> WildfireState`
538
-
539
- Access current environment state.
540
-
541
- ```python
542
- state = env.state
543
- print(f"Episode ID: {state.episode_id}")
544
- print(f"Total burned: {state.total_burned}")
545
- print(f"Total extinguished: {state.total_extinguished}")
546
- ```
547
-
548
- #### `close()`
549
-
550
- Closes the connection (for HTTP clients, this is a no-op but good practice).
551
-
552
- ```python
553
- env.close()
554
- ```
555
-
556
- ### Data Classes
557
-
558
- #### `WildfireAction`
559
-
560
- ```python
561
- @dataclass
562
- class WildfireAction(Action):
563
- action: str # "water" | "break" | "wait"
564
- x: Optional[int] = None # Target X coordinate (required for water/break)
565
- y: Optional[int] = None # Target Y coordinate (required for water/break)
566
- ```
567
-
568
- **Examples:**
569
- ```python
570
- WildfireAction(action="water", x=10, y=15)
571
- WildfireAction(action="break", x=12, y=15)
572
- WildfireAction(action="wait") # x, y not needed
573
- ```
574
-
575
- #### `WildfireObservation`
576
-
577
- See [Observations](#-observations) section for full details.
578
-
579
- #### `WildfireState`
580
-
581
- ```python
582
- @dataclass
583
- class WildfireState(State):
584
- episode_id: str
585
- step_count: int
586
- total_burned: int
587
- total_extinguished: int
588
- last_action: str
589
- width: int
590
- height: int
591
- wind_dir: str
592
- humidity: float
593
- remaining_water: int
594
- remaining_breaks: int
595
- grid: List[int]
596
- burn_timers: List[int]
597
- ```
598
-
599
- ---
600
-
601
- ## 📖 Examples
602
-
603
- ### Example 1: Simple Containment Strategy
604
-
605
- ```python
606
- from envs.wildfire_env import WildfireEnv, WildfireAction
607
- import numpy as np
608
-
609
- env = WildfireEnv(base_url="http://localhost:8000")
610
- result = env.reset()
611
- obs = result.observation
612
-
613
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
614
- total_reward = 0
615
-
616
- while not result.done:
617
- # Find burning cells
618
- burning_indices = np.where(grid_2d == 2)
619
-
620
- if len(burning_indices[0]) > 0 and obs.remaining_water > 0:
621
- # Water the first burning cell
622
- y, x = burning_indices[0][0], burning_indices[1][0]
623
- action = WildfireAction(action="water", x=int(x), y=int(y))
624
- else:
625
- # Wait if no water or no fires
626
- action = WildfireAction(action="wait")
627
-
628
- result = env.step(action)
629
- obs = result.observation
630
- total_reward += result.reward or 0
631
-
632
- # Update grid
633
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
634
-
635
- print(f"Step {obs.step}: Burning={obs.burning_count}, Reward={result.reward:.3f}")
636
-
637
- print(f"\nEpisode ended. Total reward: {total_reward:.2f}")
638
- print(f"Final stats: Burned={obs.burned_count}, Extinguished={env.state.total_extinguished}")
639
- env.close()
640
- ```
641
-
642
- ### Example 2: Firebreak Strategy
643
-
644
- ```python
645
- from envs.wildfire_env import WildfireEnv, WildfireAction
646
- import numpy as np
647
-
648
- env = WildfireEnv(base_url="http://localhost:8000")
649
- result = env.reset()
650
- obs = result.observation
651
-
652
- def create_firebreak_barrier(obs, env):
653
- """Create firebreak ahead of fire front based on wind direction."""
654
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
655
- wind = obs.wind_dir
656
-
657
- # Find burning cells
658
- burning_y, burning_x = np.where(grid_2d == 2)
659
-
660
- if len(burning_x) == 0 or obs.remaining_breaks == 0:
661
- return WildfireAction(action="wait")
662
-
663
- # Calculate fire front position
664
- if wind == "E":
665
- target_x = int(np.max(burning_x)) + 2 # Ahead of easternmost fire
666
- target_y = int(np.mean(burning_y))
667
- elif wind == "W":
668
- target_x = int(np.min(burning_x)) - 2
669
- target_y = int(np.mean(burning_y))
670
- elif wind == "N":
671
- target_x = int(np.mean(burning_x))
672
- target_y = int(np.min(burning_y)) - 2
673
- elif wind == "S":
674
- target_x = int(np.mean(burning_x))
675
- target_y = int(np.max(burning_y)) + 2
676
- else:
677
- # Fallback: water nearest burning cell
678
- return WildfireAction(action="water", x=int(burning_x[0]), y=int(burning_y[0]))
679
-
680
- # Ensure within bounds
681
- target_x = max(0, min(obs.width - 1, target_x))
682
- target_y = max(0, min(obs.height - 1, target_y))
683
-
684
- return WildfireAction(action="break", x=target_x, y=target_y)
685
-
686
- total_reward = 0
687
- while not result.done:
688
- action = create_firebreak_barrier(obs, env)
689
- result = env.step(action)
690
- obs = result.observation
691
- total_reward += result.reward or 0
692
-
693
- if obs.step % 10 == 0:
694
- print(f"Step {obs.step}: Fires={obs.burning_count}, Water={obs.remaining_water}, Breaks={obs.remaining_breaks}")
695
-
696
- env.close()
697
- ```
698
-
699
- ### Example 3: Visualization with Matplotlib
700
-
701
- ```python
702
- import matplotlib.pyplot as plt
703
- import numpy as np
704
- import matplotlib.colors as mcolors
705
- from envs.wildfire_env import WildfireEnv, WildfireAction
706
-
707
- env = WildfireEnv(base_url="http://localhost:8000")
708
- result = env.reset()
709
- obs = result.observation
710
-
711
- # Setup colormap
712
- cmap = mcolors.ListedColormap([
713
- "black", # 0 = ash
714
- "green", # 1 = fuel
715
- "red", # 2 = burning
716
- "saddlebrown", # 3 = firebreak
717
- "blue" # 4 = water
718
- ])
719
- norm = mcolors.BoundaryNorm([0, 1, 2, 3, 4, 5], cmap.N)
720
-
721
- fig, ax = plt.subplots(figsize=(8, 8))
722
- plt.ion()
723
-
724
- for step in range(50):
725
- if result.done:
726
- break
727
-
728
- # Render grid
729
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
730
- ax.clear()
731
- ax.imshow(grid_2d, cmap=cmap, norm=norm, interpolation='nearest')
732
- ax.set_title(
733
- f"Step {obs.step} | Fires: {obs.burning_count} | Burned: {obs.burned_count}\n"
734
- f"Wind: {obs.wind_dir} | Humidity: {obs.humidity:.2f} | "
735
- f"Water: {obs.remaining_water} | Breaks: {obs.remaining_breaks}"
736
- )
737
- plt.pause(0.1)
738
-
739
- # Take action (simple: water first burning cell)
740
- if obs.burning_count > 0 and obs.remaining_water > 0:
741
- burning_indices = np.where(grid_2d == 2)
742
- if len(burning_indices[0]) > 0:
743
- y, x = burning_indices[0][0], burning_indices[1][0]
744
- action = WildfireAction(action="water", x=int(x), y=int(y))
745
- else:
746
- action = WildfireAction(action="wait")
747
- else:
748
- action = WildfireAction(action="wait")
749
-
750
- result = env.step(action)
751
- obs = result.observation
752
-
753
- plt.ioff()
754
- plt.show()
755
- env.close()
756
- ```
757
-
758
- ### Example 4: Training Loop for RL
759
-
760
- ```python
761
- from envs.wildfire_env import WildfireEnv, WildfireAction
762
- import random
763
-
764
- env = WildfireEnv(base_url="http://localhost:8000")
765
-
766
- num_episodes = 10
767
- episode_rewards = []
768
-
769
- for episode in range(num_episodes):
770
- result = env.reset()
771
- obs = result.observation
772
- episode_reward = 0
773
- episode_steps = 0
774
-
775
- while not result.done:
776
- # Random policy (replace with your RL agent)
777
- if random.random() < 0.4 and obs.remaining_water > 0:
778
- action = WildfireAction(
779
- action="water",
780
- x=random.randint(0, obs.width - 1),
781
- y=random.randint(0, obs.height - 1)
782
- )
783
- elif random.random() < 0.3 and obs.remaining_breaks > 0:
784
- action = WildfireAction(
785
- action="break",
786
- x=random.randint(0, obs.width - 1),
787
- y=random.randint(0, obs.height - 1)
788
- )
789
- else:
790
- action = WildfireAction(action="wait")
791
-
792
- result = env.step(action)
793
- obs = result.observation
794
- episode_reward += result.reward or 0
795
- episode_steps += 1
796
-
797
- episode_rewards.append(episode_reward)
798
- state = env.state
799
- print(
800
- f"Episode {episode + 1}: "
801
- f"Reward={episode_reward:.2f}, "
802
- f"Steps={episode_steps}, "
803
- f"Burned={state.total_burned}, "
804
- f"Extinguished={state.total_extinguished}"
805
- )
806
-
807
- print(f"\nAverage reward: {sum(episode_rewards) / len(episode_rewards):.2f}")
808
- env.close()
809
- ```
810
-
811
- ---
812
-
813
- ## 🌐 Web Interface
814
-
815
- The Wildfire Environment includes a **custom web interface** with visual grid display and wildfire-specific features.
816
-
817
- ### Accessing the Web Interface
818
-
819
- #### Using Docker
820
-
821
- ```bash
822
- # Build base image (first time only)
823
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
824
-
825
- # Build wildfire environment
826
- docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
827
-
828
- # Run container
829
- docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
830
- ```
831
-
832
- Then open: `http://localhost:8000/web`
833
-
834
- #### Local Testing (No Docker)
835
-
836
- ```bash
837
- # Enable web interface with flag
838
- ENABLE_WEB_INTERFACE=true PYTHONPATH=src uvicorn src.envs.wildfire_env.server.app:app --reload --host 0.0.0.0 --port 8000
839
- ```
840
-
841
- ### Web Interface Features
842
-
843
- #### Left Pane: Action Interface
844
- - **Wildfire-specific action form**
845
- - Action dropdown: Water (Extinguish Fire), Break (Create Firebreak), Wait (Do Nothing)
846
- - Coordinate inputs (X, Y) - auto-populated when clicking grid cells
847
- - Coordinates show/hide based on action type
848
- - **Environment stats display**
849
- - Step count
850
- - Water remaining
851
- - Breaks remaining
852
- - Burning cells count
853
- - **Current state display**
854
- - Status (Reset/Running)
855
- - Episode ID
856
- - Wind direction
857
- - Humidity
858
- - **Control buttons**
859
- - Reset Environment
860
- - Get State
861
-
862
- #### Right Pane: Visual Grid & Logs
863
- - **Visual 2D Grid Display** 🔥
864
- - 16×16 grid rendered as color-coded cells
865
- - **Color coding:**
866
- - 🟩 **Green** = Fuel (safe, value 1)
867
- - 🔥 **Orange/Red** = Burning (fire, value 2)
868
- - ⬛ **Dark Gray** = Ash (burned, value 0)
869
- - 🟫 **Brown** = Firebreak (value 3)
870
- - 🟦 **Blue** = Watered/Damp (value 4)
871
- - **Interactive:** Click cells to set coordinates for water/break actions
872
- - **Auto-updates:** Grid refreshes automatically via WebSocket
873
- - **Legend**
874
- - Color-coded legend explaining all cell types
875
- - **Action history**
876
- - Log of all actions with timestamps
877
- - Shows action, observation, reward, and done status
878
-
879
- #### Additional Features
880
- - **WebSocket connection** - Real-time state updates without page refresh
881
- - **Instructions panel** - Collapsible environment documentation
882
- - **Grid status indicator** - Shows grid dimensions and cell count
883
-
884
- ### Using the Web Interface
885
-
886
- 1. **Start the server** (see above)
887
- 2. **Open browser** to: `http://localhost:8000/web`
888
- 3. **Click "Reset Environment"** to initialize and display the grid
889
- 4. **Interact with the grid:**
890
- - Click on a cell to set coordinates for water/break actions
891
- - Or manually enter X, Y coordinates
892
- 5. **Select action:**
893
- - Choose `water`, `break`, or `wait` from the dropdown
894
- 6. **Click "Execute Action"**
895
- 7. **Watch the grid update in real-time:**
896
- - Fire spreads automatically
897
- - Cells change color based on state
898
- - Stats update automatically
899
- 8. **Monitor resources** in the stats panel (water, breaks, burning count)
900
-
901
- ---
902
-
903
- ## 🔧 Troubleshooting
904
-
905
- ### Common Issues
906
-
907
- #### 1. Connection Errors
908
-
909
- **Problem:** `ConnectionRefusedError` or `Cannot connect to server`
910
-
911
- **Solutions:**
912
- - Verify server is running: `curl http://localhost:8000/health`
913
- - Check Docker container: `docker ps | grep wildfire`
914
- - Ensure port 8000 is not in use: `lsof -i :8000`
915
-
916
- #### 2. Index Errors
917
-
918
- **Problem:** `IndexError: list index out of range`
919
-
920
- **Solution:** Ensure coordinates are within bounds:
921
- ```python
922
- # Always check bounds before accessing
923
- if 0 <= x < obs.width and 0 <= y < obs.height:
924
- action = WildfireAction(action="water", x=x, y=y)
925
- ```
926
-
927
- #### 3. Invalid Action Warnings
928
-
929
- **Problem:** Actions returning -0.05 reward repeatedly
930
-
931
- **Solutions:**
932
- - Check `remaining_water` and `remaining_breaks` before using resources
933
- - Verify coordinates are integers and within grid bounds
934
- - Use `action="wait"` when resources are exhausted
935
-
936
- #### 4. Grid Format Confusion
937
-
938
- **Problem:** How to access grid cells?
939
-
940
- **Solution:**
941
- ```python
942
- # Convert flat array to 2D
943
- grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
944
-
945
- # Access cell at (x, y)
946
- cell_value = grid_2d[y][x]
947
-
948
- # Or use flat index
949
- index = y * obs.width + x
950
- cell_value = obs.grid[index]
951
- ```
952
-
953
- #### 5. Docker Build Failures
954
-
955
- **Problem:** `failed to solve: openenv-base:latest`
956
-
957
- **Solution:**
958
- ```bash
959
- # Build base image first
960
- docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
961
-
962
- # Then build wildfire image
963
- docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
964
- ```
965
-
966
- ### Debugging Tips
967
-
968
- 1. **Enable verbose logging:**
969
- ```bash
970
- docker logs -f wildfire-env-container
971
- ```
972
-
973
- 2. **Check environment state:**
974
- ```python
975
- state = env.state
976
- print(f"State: {state}")
977
- ```
978
-
979
- 3. **Validate actions:**
980
- ```python
981
- obs = env.reset().observation
982
- print(f"Bounds: 0 <= x < {obs.width}, 0 <= y < {obs.height}")
983
- print(f"Resources: Water={obs.remaining_water}, Breaks={obs.remaining_breaks}")
984
- ```
985
-
986
- 4. **Monitor grid changes:**
987
- ```python
988
- prev_grid = obs.grid.copy()
989
- result = env.step(action)
990
- new_grid = result.observation.grid
991
- changes = [i for i, (a, b) in enumerate(zip(prev_grid, new_grid)) if a != b]
992
- print(f"Changed cells: {len(changes)}")
993
- ```
994
-
995
- ---
996
-
997
- ## 📊 Performance Considerations
998
-
999
- ### Grid Size Impact
1000
-
1001
- - **Small grids (16×16)**: Fast, good for quick testing
1002
- - **Medium grids (32×32)**: Default, balanced performance
1003
- - **Large grids (64×64+)**: Slower, more realistic but requires more compute
1004
-
1005
- ### Resource Limits
1006
-
1007
- - **Low water (4-8)**: Forces strategic decisions
1008
- - **High water (20+)**: More forgiving, easier to succeed
1009
- - **Low breaks (25)**: Emphasizes firebreak placement strategy
1010
- - **High breaks (100+)**: More freedom, less constraint
1011
-
1012
- ### Episode Length
1013
-
1014
- - **Short episodes (50 steps)**: Fast iteration, good for debugging
1015
- - **Medium episodes (128 steps)**: Default, balanced
1016
- - **Long episodes (200+ steps)**: Better for complex strategies
1017
-
1018
- ---
1019
-
1020
- ## 🧭 References
1021
-
1022
- ### Papers & Research
1023
-
1024
- - **Rothermel Model**: [USDA Forest Service - Surface Fire Spread Model](https://www.fs.fed.us/rm/pubs_series/rmrs/gtr/rmrs_gtr371.pdf)
1025
- - **SimFire**: [MITRE Fireline Project](https://github.com/mitrefireline/simfire)
1026
- - **RL for Wildfires**: [arXiv:2311.15925](https://arxiv.org/abs/2311.15925)
1027
-
1028
- ### OpenEnv Framework
1029
-
1030
- - **Main Repository**: [OpenEnv GitHub](https://github.com/openenv)
1031
- - **Documentation**: See `rfcs/` directory for design documents
1032
- - **Other Environments**: See `src/envs/` for more environment examples
1033
-
1034
- ### Related Tools
1035
-
1036
- - **FastAPI**: [FastAPI Documentation](https://fastapi.tiangolo.com/)
1037
- - **Reinforcement Learning**: [Spinning Up in Deep RL](https://spinningup.openai.com/)
1038
- - **Docker**: [Docker Documentation](https://docs.docker.com/)
1039
-
1040
- ---
1041
-
1042
- ## 📝 License
1043
-
1044
- This environment is part of the OpenEnv project. See the main LICENSE file for details.
1045
-
1046
- ---
1047
-
1048
- ## 🤝 Contributing
1049
-
1050
- Contributions welcome! Please see `CONTRIBUTING.md` in the main OpenEnv repository.
1051
-
1052
- ---
1053
-
1054
- ## 🔖 Citations
1055
-
1056
- ```bibtex
1057
- @techreport{rothermel2022surface,
1058
- title = {The Rothermel Surface Fire Spread Model and Associated Developments},
1059
- author = {Andrews, Patricia L. and Rothermel, Richard C.},
1060
- year = {2022},
1061
- institution = {USDA Forest Service},
1062
- number = {RMRS-GTR-371},
1063
- url = {https://www.fs.usda.gov/rm/pubs_series/rmrs/gtr/rmrs_gtr371.pdf}
1064
- }
1065
-
1066
- @article{tapley2023reinforcement,
1067
- title = {Reinforcement Learning for Wildfire Mitigation in Simulated Disaster Environments},
1068
- author = {Tapley, A. and Dotter, M. and Doyle, M. and others},
1069
- journal = {arXiv preprint arXiv:2311.15925},
1070
- year = {2023},
1071
- url = {https://arxiv.org/abs/2311.15925}
1072
- }
1073
-
1074
- @misc{mitrefireline2023simfire,
1075
- author = {{MITRE Fireline Project}},
1076
- title = {SimFire: Wildfire Simulator for Decision-Support and AI Research},
1077
- year = {2023},
1078
- howpublished = {\url{https://github.com/mitrefireline/simfire}}
1079
- }
1080
-
1081
- @misc{wildfire-openenv-2025,
1082
- title = {Wildfire Environment for OpenEnv: Containment-Focused RL Simulation},
1083
- author = {OpenEnv Contributors},
1084
- year = {2025},
1085
- url = {https://github.com/openenv/openenv}
1086
- }
1087
- ```
1088
-
1089
- ---
1090
-
1091
- **Happy firefighting! 🔥🚒**
 
1
+ ---
2
+ title: Wildfire Environment Server
3
+ emoji: 🔥
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - reinforcement-learning
13
+ - wildfire
14
+ - simulation
15
+ ---
16
+
17
+ # 🌲 Wildfire Environment
18
+
19
+ Autonomous wildfire-control simulation for reinforcement-learning agents, built on the [OpenEnv](https://github.com/openenv) framework.
20
+ Agents must contain spreading fires using **water**, **firebreaks**, and **timing strategies** under changing **wind** and **humidity** conditions.
21
+
22
+ [![Docker](https://img.shields.io/badge/docker-ready-blue)](https://hub.docker.com/)
23
+ [![Python](https://img.shields.io/badge/python-3.10+-green)](https://www.python.org/)
24
+ [![FastAPI](https://img.shields.io/badge/backend-fastapi-teal)](https://fastapi.tiangolo.com/)
25
+ [![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)
26
+
27
+ ---
28
+
29
+ ## 📋 Table of Contents
30
+
31
+ 1. [Why Wildfire Simulation?](#-why-wildfire-simulation)
32
+ 2. [Quick Start](#-quick-start)
33
+ 3. [Environment Overview](#-environment-overview)
34
+ 4. [Grid Format & Encoding](#-grid-format--encoding)
35
+ 5. [Actions](#-actions)
36
+ 6. [Observations](#-observations)
37
+ 7. [Reward Structure](#-reward-structure)
38
+ 8. [Fire Spread Mechanics](#-fire-spread-mechanics)
39
+ 9. [Configuration](#-configuration)
40
+ 10. [Installation & Usage](#-installation--usage)
41
+ 11. [API Reference](#-api-reference)
42
+ 12. [Examples](#-examples)
43
+ 13. [Web Interface](#-web-interface)
44
+ 14. [Troubleshooting](#-troubleshooting)
45
+ 15. [References](#-references)
46
+
47
+ ---
48
+
49
+ ## 🔥 Why Wildfire Simulation?
50
+
51
+ Wildland fires are intensifying globally due to climate change — increasing the urgency for **AI-assisted decision-making**.
52
+ This environment explores how intelligent systems can **control** fire spread in real time, under limited resources.
53
+
54
+ ### Research Motivation
55
+ ✅ Based on real wildfire science inspired by:
56
+ - **Rothermel Surface Fire Spread Model** (USDA Forest Service)
57
+ - **MITRE Fireline's SimFire** — physics-informed RL fire simulator
58
+ - **SimHarness** — RL evaluation for disaster response
59
+
60
+ ### Application Goals
61
+ | Research Theme | Role in This Environment |
62
+ |---|---|
63
+ | Resource-Constrained Planning | Finite water + firebreak budgets |
64
+ | Fire Spread + Containment Strategy | Directional wind & moisture effects |
65
+ | Disaster Response RL | Safety-focused reward design |
66
+ | LLM Agents for Control Tasks | Text-based action decision making |
67
+
68
+ This makes WildfireEnv a **fast, controllable**, and **open benchmark** for applied RL and LLM reasoning.
69
+
70
+ ---
71
+
72
+ ## 🚀 Quick Start
73
+
74
+ ### Using Docker (Recommended)
75
+
76
+ ```bash
77
+ # Build base image (first time only)
78
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
79
+
80
+ # Build wildfire environment
81
+ docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
82
+
83
+ # Run container
84
+ docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
85
+ ```
86
+
87
+ **Note:** The web interface can be enabled with `ENABLE_WEB_INTERFACE=true`. Access it at `http://localhost:8000/web` when enabled.
88
+
89
+ ### Basic Python Client
90
+
91
+ ```python
92
+ from envs.wildfire_env import WildfireEnv, WildfireAction
93
+
94
+ # Connect to running server
95
+ env = WildfireEnv(base_url="http://localhost:8000")
96
+
97
+ # Reset environment
98
+ result = env.reset()
99
+ obs = result.observation
100
+ print(f"Grid: {obs.width}x{obs.height}, Fires: {obs.burning_count}, Water: {obs.remaining_water}")
101
+
102
+ # Take action (water a burning cell)
103
+ result = env.step(WildfireAction(action="water", x=10, y=15))
104
+ print(f"Reward: {result.reward:.2f}, Burning: {result.observation.burning_count}")
105
+
106
+ # Create firebreak
107
+ result = env.step(WildfireAction(action="break", x=12, y=15))
108
+
109
+ # Wait (fire spreads)
110
+ result = env.step(WildfireAction(action="wait"))
111
+
112
+ env.close()
113
+ ```
114
+
115
+ ---
116
+
117
+ ## 🔥 Environment Overview
118
+
119
+ This environment models **forest-fire dynamics** influenced by:
120
+ - **Wind direction** (8 directions + calm) - accelerates fire spread in wind direction
121
+ - **Humidity** (0.0-1.0) - suppresses ignition probability
122
+ - **Fuel type and spread rate** - vegetation burns and spreads to neighbors
123
+ - **Limited resources** (water units, break materials) - strategic resource management
124
+ - **Time pressure** (each step costs small reward penalty)
125
+
126
+ The goal is to **minimize fire spread** and **total burned area** while using resources efficiently.
127
+
128
+ ### Episode Termination
129
+
130
+ An episode ends when:
131
+ - **All fires are extinguished** (`burning_count == 0`) - **Success!**
132
+ - **Maximum steps reached** (`step_count >= max_steps`) - Time limit exceeded
133
+
134
+ ---
135
+
136
+ ## 🧱 Grid Format & Encoding
137
+
138
+ ### Grid Structure
139
+
140
+ The grid is returned as a **flat 1D array** in the observation. To access cell at position `(x, y)`:
141
+
142
+ ```python
143
+ index = y * width + x
144
+ cell_value = observation.grid[index]
145
+ ```
146
+
147
+ **Example:** For a 32×32 grid, cell at (10, 15):
148
+ ```python
149
+ index = 15 * 32 + 10 # = 490
150
+ cell_value = observation.grid[490]
151
+ ```
152
+
153
+ ### Cell Encoding
154
+
155
+ | Code | Meaning | Color (Visualization) | Behavior |
156
+ |------|----------------|-----------------------|----------|
157
+ | `0` | Ash (burned) | Black ⚫ | Burned out, cannot reignite |
158
+ | `1` | Fuel | Green 🟩 | Healthy vegetation, can ignite |
159
+ | `2` | Burning | Red 🔥 | Currently on fire, spreads to neighbors |
160
+ | `3` | Firebreak | Brown 🟫 | Barrier, fire cannot cross |
161
+ | `4` | Water/Damp | Blue 🔵 | Dampened, immune to ignition temporarily |
162
+
163
+ ### Grid Visualization Example
164
+
165
+ ```python
166
+ import numpy as np
167
+
168
+ obs = env.reset().observation
169
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
170
+
171
+ # Now grid_2d[y][x] gives the cell value at position (x, y)
172
+ print(grid_2d[15][10]) # Cell at x=10, y=15
173
+ ```
174
+
175
+ ---
176
+
177
+ ## 🎮 Actions
178
+
179
+ ### Action Types
180
+
181
+ #### 1. `water` - Apply Water
182
+ **Extinguishes burning cells and dampens fuel to prevent ignition.**
183
+
184
+ ```python
185
+ WildfireAction(action="water", x=10, y=15)
186
+ ```
187
+
188
+ **Effects:**
189
+ - **Burning cell (2)**: Extinguishes → becomes Water/Damp (4), gives **+0.25 reward**
190
+ - **Fuel cell (1)**: Dampens → becomes Water/Damp (4), gives **-0.10 reward** (preventive, slight penalty)
191
+ - **Water/Damp cell (4)**: Redundant watering, gives **-0.05 reward**
192
+ - **Ash/Break (0, 3)**: Wasteful, gives **-0.05 reward**
193
+
194
+ **Resource Cost:** 1 water unit per action
195
+ **Requires:** `remaining_water > 0` and valid coordinates
196
+
197
+ **Best Use:** Extinguish active fires before they spread
198
+
199
+ ---
200
+
201
+ #### 2. `break` - Create Firebreak
202
+ **Builds a fire-resistant barrier that stops fire spread.**
203
+
204
+ ```python
205
+ WildfireAction(action="break", x=12, y=15)
206
+ ```
207
+
208
+ **Effects:**
209
+ - **Fuel/Water cell (1, 4)**: Creates firebreak → becomes Firebreak (3), gives **+0.15 reward**
210
+ - **Burning cell (2)**: Extinguishes → becomes Firebreak (3), gives **-0.02 reward** (less effective than water)
211
+ - **Firebreak (3)**: Redundant, gives **-0.01 reward**
212
+ - **Ash (0)**: Wasteful, gives **-0.02 reward**
213
+
214
+ **Resource Cost:** 1 firebreak material per action
215
+ **Requires:** `remaining_breaks > 0` and valid coordinates
216
+
217
+ **Best Use:** Create barriers ahead of fire front to contain spread
218
+
219
+ ---
220
+
221
+ #### 3. `wait` - Do Nothing
222
+ **Let natural fire dynamics occur (fire spreads).**
223
+
224
+ ```python
225
+ WildfireAction(action="wait")
226
+ ```
227
+
228
+ **Effects:**
229
+ - No resource cost
230
+ - No coordinate required
231
+ - Fire spreads naturally to neighboring cells
232
+ - Small time penalty (-0.01 reward per step)
233
+
234
+ **Best Use:** When fire is contained, waiting for it to burn out
235
+
236
+ ---
237
+
238
+ ### Invalid Actions
239
+
240
+ Actions that fail (give **-0.05 reward**):
241
+ - Invalid coordinates (out of bounds)
242
+ - Using water when `remaining_water == 0`
243
+ - Using break when `remaining_breaks == 0`
244
+ - Missing required coordinates for water/break actions
245
+
246
+ ---
247
+
248
+ ## 👁️ Observations
249
+
250
+ ### `WildfireObservation`
251
+
252
+ Returned after every `reset()` or `step()`:
253
+
254
+ ```python
255
+ @dataclass
256
+ class WildfireObservation(Observation):
257
+ grid: List[int] # Flat array: [1,1,2,1,...] length = width × height
258
+ width: int # Grid width (default: 32)
259
+ height: int # Grid height (default: 32)
260
+ step: int # Current step number (0 at reset)
261
+ wind_dir: str # "N", "NE", "E", "SE", "S", "SW", "W", "NW", "CALM"
262
+ humidity: float # [0.0, 1.0] - higher = less fire spread
263
+ burning_count: int # Number of cells currently on fire
264
+ burned_count: int # Total number of ash cells (cumulative)
265
+ remaining_water: int # Water units left
266
+ remaining_breaks: int # Firebreak materials left
267
+ reward_hint: float # Shaping reward (for debugging)
268
+ done: bool # Episode ended?
269
+ reward: float # Step reward
270
+ ```
271
+
272
+ ### Example Observation
273
+
274
+ ```python
275
+ result = env.reset()
276
+ obs = result.observation
277
+
278
+ print(f"Step: {obs.step}") # 0
279
+ print(f"Grid size: {obs.width}x{obs.height}") # 32x32
280
+ print(f"Grid cells: {len(obs.grid)}") # 1024
281
+ print(f"Active fires: {obs.burning_count}") # 2
282
+ print(f"Wind: {obs.wind_dir}") # "NE"
283
+ print(f"Humidity: {obs.humidity:.2f}") # 0.24
284
+ print(f"Water left: {obs.remaining_water}") # 8
285
+ print(f"Breaks left: {obs.remaining_breaks}") # 50
286
+ ```
287
+
288
+ ---
289
+
290
+ ## 💰 Reward Structure
291
+
292
+ ### Step Rewards
293
+
294
+ | Action | Condition | Reward |
295
+ |--------|-----------|--------|
296
+ | **Water burning cell** | Extinguishes fire | **+0.25** |
297
+ | **Water fuel cell** | Preventive dampening | **-0.10** |
298
+ | **Create firebreak** | From fuel/water | **+0.15** |
299
+ | **Fire spreads** | Each new burning cell | **-0.15 per cell** |
300
+ | **Fire shrinks** | Each extinguished cell | **+0.10 per cell** |
301
+ | **New burned area** | Each cell turns to ash | **-0.05 per cell** |
302
+ | **Time penalty** | Every step | **-0.01** |
303
+ | **Invalid action** | Out of bounds, no resources | **-0.05** |
304
+ | **Redundant action** | Watering already damp cell | **-0.05** |
305
+
306
+ ### Episode End Bonuses
307
+
308
+ When episode terminates (`done == True`):
309
+
310
+ - **Fire contained** (`burning_count == 0`):
311
+ - **+0.5** base bonus
312
+ - **+0.5 × saved_ratio** bonus (proportion of cells not burned)
313
+
314
+ - **Fallback reward**:
315
+ - **+0.2 × (1.0 - burned_ratio)** bonus
316
+
317
+ **Example:** Perfect containment (no burned cells):
318
+ ```python
319
+ Reward = +0.5 + 0.5 × 1.0 = +1.0
320
+ ```
321
+
322
+ ### Reward Interpretation
323
+
324
+ - **Positive rewards**: Good containment actions, extinguishing fires
325
+ - **Negative rewards**: Fire spread, resource waste, time penalty
326
+ - **Goal**: Maximize cumulative reward = minimize fire damage
327
+
328
+ ---
329
+
330
+ ## 🌪️ Fire Spread Mechanics
331
+
332
+ ### Spread Model
333
+
334
+ Fire spreads using an **8-directional neighbor model**:
335
+
336
+ 1. **Burning cells persist** for `burn_lifetime = 3` ticks before turning to ash
337
+ 2. Each burning cell can ignite **neighboring fuel cells** (8 directions)
338
+ 3. Spread probability depends on:
339
+ - **Base ignition probability**: `0.30` (30% chance)
340
+ - **Humidity factor**: `(1.0 - humidity)` - higher humidity = less spread
341
+ - **Wind multiplier**:
342
+ - **+2.0x** in wind direction
343
+ - **+0.5x** against wind
344
+ - **+1.0x** perpendicular
345
+ - **Diagonal factor**: `0.6x` for diagonal neighbors (slower spread)
346
+
347
+ 4. **Water/Damp cells (4)** are **immune** to ignition while damp
348
+ 5. **Firebreaks (3)** **cannot** be crossed by fire
349
+ 6. **Ash cells (0)** cannot reignite
350
+
351
+ ### Wind Effects
352
+
353
+ | Wind Direction | Effect on Fire Spread |
354
+ |----------------|----------------------|
355
+ | **In wind direction** | 2× faster ignition probability |
356
+ | **Against wind** | 0.5× slower ignition probability |
357
+ | **Perpendicular** | Normal (1×) ignition probability |
358
+ | **CALM** | No directional bias |
359
+
360
+ ### Water Dampening Duration
361
+
362
+ Watered cells (4) remain damp for **6 ticks** before reverting to fuel (1).
363
+
364
+ ### Example Fire Spread
365
+
366
+ ```
367
+ Step 0: Step 1: Step 2:
368
+ 🟩🟩🟩 🟩🟥🟩 🟫🟥🟫
369
+ 🟩🟥🟩 → 🟥🟥🟥 → 🟥🟥🟥 (Wind: E, spreading east)
370
+ 🟩🟩🟩 🟩🟥🟩 🟫🟥🟫
371
+ ```
372
+
373
+ ---
374
+
375
+ ## ⚙️ Configuration
376
+
377
+ ### Environment Variables
378
+
379
+ Set these **before starting the server**:
380
+
381
+ | Variable | Description | Default | Range |
382
+ |-----------|-------------|---------|-------|
383
+ | `WILDFIRE_WIDTH` | Grid width in cells | `32` | 8-128 |
384
+ | `WILDFIRE_HEIGHT` | Grid height in cells | `32` | 8-128 |
385
+ | `WILDFIRE_HUMIDITY` | Initial humidity level | `0.25` | 0.0-1.0 |
386
+ | `WILDFIRE_WIND` | Wind direction (fixed) | Random | `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW`, `CALM` |
387
+ | `WILDFIRE_SEED` | Random seed | `3407` | Any integer |
388
+ | `WILDFIRE_MAX_STEPS` | Max steps per episode | `128` | 10-1000 |
389
+ | `WILDFIRE_WATER_CAPACITY` | Initial water units | `8` | 1-100 |
390
+ | `WILDFIRE_BREAK_CAPACITY` | Initial firebreak materials | `50` | 1-200 |
391
+
392
+ ### Python API Configuration
393
+
394
+ ```python
395
+ from envs.wildfire_env.server.wildfire_environment import WildfireEnvironment
396
+
397
+ env = WildfireEnvironment(
398
+ width=64,
399
+ height=64,
400
+ humidity=0.3,
401
+ init_sources=3, # Number of initial fires
402
+ max_steps=200,
403
+ water_capacity=10,
404
+ break_capacity=75,
405
+ seed=42
406
+ )
407
+ ```
408
+
409
+ ### Docker Configuration
410
+
411
+ ```bash
412
+ docker run -p 8000:8000 \
413
+ -e WILDFIRE_WIDTH=64 \
414
+ -e WILDFIRE_HEIGHT=64 \
415
+ -e WILDFIRE_HUMIDITY=0.4 \
416
+ -e WILDFIRE_WIND=N \
417
+ -e WILDFIRE_WATER_CAPACITY=12 \
418
+ wildfire-env:latest
419
+ ```
420
+
421
+ ### Custom Configuration
422
+
423
+ ```bash
424
+ # Build and run with custom configuration
425
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
426
+ docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
427
+ docker run -p 8000:8000 \
428
+ -e ENABLE_WEB_INTERFACE=true \
429
+ -e WILDFIRE_WIDTH=64 \
430
+ -e WILDFIRE_HEIGHT=64 \
431
+ -e WILDFIRE_HUMIDITY=0.5 \
432
+ wildfire-env:latest
433
+ ```
434
+
435
+ ---
436
+
437
+ ## 🚀 Installation & Usage
438
+
439
+ ### Option 1: Docker (Recommended)
440
+
441
+ **Manual setup:**
442
+ ```bash
443
+ # Build base image (first time only)
444
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
445
+
446
+ # Build wildfire environment
447
+ docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
448
+
449
+ # Run container
450
+ docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
451
+ ```
452
+
453
+ This approach:
454
+ - Builds the base image if needed
455
+ - Rebuilds the wildfire image
456
+ - Starts the container
457
+ - Shows logs in real-time
458
+
459
+ **Alternative: Using build_docker.sh script:**
460
+ ```bash
461
+ # Build base image (first time only)
462
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
463
+
464
+ # Build wildfire environment using the script
465
+ cd src/envs/wildfire_env/server
466
+ ./build_docker.sh
467
+
468
+ # Run container
469
+ docker run -d -p 8000:8000 --name wildfire-env-container wildfire-env:latest
470
+
471
+ # View logs
472
+ docker logs -f wildfire-env-container
473
+
474
+ # Stop container
475
+ docker stop wildfire-env-container
476
+
477
+ # Remove container
478
+ docker rm wildfire-env-container
479
+ ```
480
+
481
+ ### Option 2: Local Development (No Docker)
482
+
483
+ **Requirements:**
484
+ ```bash
485
+ pip install fastapi uvicorn numpy matplotlib requests
486
+ ```
487
+
488
+ **Run server:**
489
+ ```bash
490
+ # From OpenEnv root directory
491
+ python -m envs.wildfire_env.server.app
492
+ ```
493
+
494
+ **Or with environment variables:**
495
+ ```bash
496
+ WILDFIRE_WIDTH=64 WILDFIRE_HUMIDITY=0.3 python -m envs.wildfire_env.server.app
497
+ ```
498
+
499
+ ---
500
+
501
+ ## 📚 API Reference
502
+
503
+ ### Client Class
504
+
505
+ ```python
506
+ from envs.wildfire_env import WildfireEnv
507
+
508
+ # Connect to existing server
509
+ env = WildfireEnv(base_url="http://localhost:8000")
510
+
511
+ # Or create from Docker image
512
+ env = WildfireEnv.from_docker_image("wildfire-env:latest")
513
+ ```
514
+
515
+ ### Methods
516
+
517
+ #### `reset() -> StepResult[WildfireObservation]`
518
+
519
+ Resets the environment to initial state.
520
+
521
+ ```python
522
+ result = env.reset()
523
+ obs = result.observation
524
+ print(f"New episode: {obs.step == 0}")
525
+ ```
526
+
527
+ #### `step(action: WildfireAction) -> StepResult[WildfireObservation]`
528
+
529
+ Takes an action and returns new observation.
530
+
531
+ ```python
532
+ action = WildfireAction(action="water", x=10, y=15)
533
+ result = env.step(action)
534
+ print(f"Reward: {result.reward}, Done: {result.done}")
535
+ ```
536
+
537
+ #### `state -> WildfireState`
538
+
539
+ Access current environment state.
540
+
541
+ ```python
542
+ state = env.state
543
+ print(f"Episode ID: {state.episode_id}")
544
+ print(f"Total burned: {state.total_burned}")
545
+ print(f"Total extinguished: {state.total_extinguished}")
546
+ ```
547
+
548
+ #### `close()`
549
+
550
+ Closes the connection (for HTTP clients, this is a no-op but good practice).
551
+
552
+ ```python
553
+ env.close()
554
+ ```
555
+
556
+ ### Data Classes
557
+
558
+ #### `WildfireAction`
559
+
560
+ ```python
561
+ @dataclass
562
+ class WildfireAction(Action):
563
+ action: str # "water" | "break" | "wait"
564
+ x: Optional[int] = None # Target X coordinate (required for water/break)
565
+ y: Optional[int] = None # Target Y coordinate (required for water/break)
566
+ ```
567
+
568
+ **Examples:**
569
+ ```python
570
+ WildfireAction(action="water", x=10, y=15)
571
+ WildfireAction(action="break", x=12, y=15)
572
+ WildfireAction(action="wait") # x, y not needed
573
+ ```
574
+
575
+ #### `WildfireObservation`
576
+
577
+ See [Observations](#-observations) section for full details.
578
+
579
+ #### `WildfireState`
580
+
581
+ ```python
582
+ @dataclass
583
+ class WildfireState(State):
584
+ episode_id: str
585
+ step_count: int
586
+ total_burned: int
587
+ total_extinguished: int
588
+ last_action: str
589
+ width: int
590
+ height: int
591
+ wind_dir: str
592
+ humidity: float
593
+ remaining_water: int
594
+ remaining_breaks: int
595
+ grid: List[int]
596
+ burn_timers: List[int]
597
+ ```
598
+
599
+ ---
600
+
601
+ ## 📖 Examples
602
+
603
+ ### Example 1: Simple Containment Strategy
604
+
605
+ ```python
606
+ from envs.wildfire_env import WildfireEnv, WildfireAction
607
+ import numpy as np
608
+
609
+ env = WildfireEnv(base_url="http://localhost:8000")
610
+ result = env.reset()
611
+ obs = result.observation
612
+
613
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
614
+ total_reward = 0
615
+
616
+ while not result.done:
617
+ # Find burning cells
618
+ burning_indices = np.where(grid_2d == 2)
619
+
620
+ if len(burning_indices[0]) > 0 and obs.remaining_water > 0:
621
+ # Water the first burning cell
622
+ y, x = burning_indices[0][0], burning_indices[1][0]
623
+ action = WildfireAction(action="water", x=int(x), y=int(y))
624
+ else:
625
+ # Wait if no water or no fires
626
+ action = WildfireAction(action="wait")
627
+
628
+ result = env.step(action)
629
+ obs = result.observation
630
+ total_reward += result.reward or 0
631
+
632
+ # Update grid
633
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
634
+
635
+ print(f"Step {obs.step}: Burning={obs.burning_count}, Reward={result.reward:.3f}")
636
+
637
+ print(f"\nEpisode ended. Total reward: {total_reward:.2f}")
638
+ print(f"Final stats: Burned={obs.burned_count}, Extinguished={env.state.total_extinguished}")
639
+ env.close()
640
+ ```
641
+
642
+ ### Example 2: Firebreak Strategy
643
+
644
+ ```python
645
+ from envs.wildfire_env import WildfireEnv, WildfireAction
646
+ import numpy as np
647
+
648
+ env = WildfireEnv(base_url="http://localhost:8000")
649
+ result = env.reset()
650
+ obs = result.observation
651
+
652
+ def create_firebreak_barrier(obs, env):
653
+ """Create firebreak ahead of fire front based on wind direction."""
654
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
655
+ wind = obs.wind_dir
656
+
657
+ # Find burning cells
658
+ burning_y, burning_x = np.where(grid_2d == 2)
659
+
660
+ if len(burning_x) == 0 or obs.remaining_breaks == 0:
661
+ return WildfireAction(action="wait")
662
+
663
+ # Calculate fire front position
664
+ if wind == "E":
665
+ target_x = int(np.max(burning_x)) + 2 # Ahead of easternmost fire
666
+ target_y = int(np.mean(burning_y))
667
+ elif wind == "W":
668
+ target_x = int(np.min(burning_x)) - 2
669
+ target_y = int(np.mean(burning_y))
670
+ elif wind == "N":
671
+ target_x = int(np.mean(burning_x))
672
+ target_y = int(np.min(burning_y)) - 2
673
+ elif wind == "S":
674
+ target_x = int(np.mean(burning_x))
675
+ target_y = int(np.max(burning_y)) + 2
676
+ else:
677
+ # Fallback: water nearest burning cell
678
+ return WildfireAction(action="water", x=int(burning_x[0]), y=int(burning_y[0]))
679
+
680
+ # Ensure within bounds
681
+ target_x = max(0, min(obs.width - 1, target_x))
682
+ target_y = max(0, min(obs.height - 1, target_y))
683
+
684
+ return WildfireAction(action="break", x=target_x, y=target_y)
685
+
686
+ total_reward = 0
687
+ while not result.done:
688
+ action = create_firebreak_barrier(obs, env)
689
+ result = env.step(action)
690
+ obs = result.observation
691
+ total_reward += result.reward or 0
692
+
693
+ if obs.step % 10 == 0:
694
+ print(f"Step {obs.step}: Fires={obs.burning_count}, Water={obs.remaining_water}, Breaks={obs.remaining_breaks}")
695
+
696
+ env.close()
697
+ ```
698
+
699
+ ### Example 3: Visualization with Matplotlib
700
+
701
+ ```python
702
+ import matplotlib.pyplot as plt
703
+ import numpy as np
704
+ import matplotlib.colors as mcolors
705
+ from envs.wildfire_env import WildfireEnv, WildfireAction
706
+
707
+ env = WildfireEnv(base_url="http://localhost:8000")
708
+ result = env.reset()
709
+ obs = result.observation
710
+
711
+ # Setup colormap
712
+ cmap = mcolors.ListedColormap([
713
+ "black", # 0 = ash
714
+ "green", # 1 = fuel
715
+ "red", # 2 = burning
716
+ "saddlebrown", # 3 = firebreak
717
+ "blue" # 4 = water
718
+ ])
719
+ norm = mcolors.BoundaryNorm([0, 1, 2, 3, 4, 5], cmap.N)
720
+
721
+ fig, ax = plt.subplots(figsize=(8, 8))
722
+ plt.ion()
723
+
724
+ for step in range(50):
725
+ if result.done:
726
+ break
727
+
728
+ # Render grid
729
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
730
+ ax.clear()
731
+ ax.imshow(grid_2d, cmap=cmap, norm=norm, interpolation='nearest')
732
+ ax.set_title(
733
+ f"Step {obs.step} | Fires: {obs.burning_count} | Burned: {obs.burned_count}\n"
734
+ f"Wind: {obs.wind_dir} | Humidity: {obs.humidity:.2f} | "
735
+ f"Water: {obs.remaining_water} | Breaks: {obs.remaining_breaks}"
736
+ )
737
+ plt.pause(0.1)
738
+
739
+ # Take action (simple: water first burning cell)
740
+ if obs.burning_count > 0 and obs.remaining_water > 0:
741
+ burning_indices = np.where(grid_2d == 2)
742
+ if len(burning_indices[0]) > 0:
743
+ y, x = burning_indices[0][0], burning_indices[1][0]
744
+ action = WildfireAction(action="water", x=int(x), y=int(y))
745
+ else:
746
+ action = WildfireAction(action="wait")
747
+ else:
748
+ action = WildfireAction(action="wait")
749
+
750
+ result = env.step(action)
751
+ obs = result.observation
752
+
753
+ plt.ioff()
754
+ plt.show()
755
+ env.close()
756
+ ```
757
+
758
+ ### Example 4: Training Loop for RL
759
+
760
+ ```python
761
+ from envs.wildfire_env import WildfireEnv, WildfireAction
762
+ import random
763
+
764
+ env = WildfireEnv(base_url="http://localhost:8000")
765
+
766
+ num_episodes = 10
767
+ episode_rewards = []
768
+
769
+ for episode in range(num_episodes):
770
+ result = env.reset()
771
+ obs = result.observation
772
+ episode_reward = 0
773
+ episode_steps = 0
774
+
775
+ while not result.done:
776
+ # Random policy (replace with your RL agent)
777
+ if random.random() < 0.4 and obs.remaining_water > 0:
778
+ action = WildfireAction(
779
+ action="water",
780
+ x=random.randint(0, obs.width - 1),
781
+ y=random.randint(0, obs.height - 1)
782
+ )
783
+ elif random.random() < 0.3 and obs.remaining_breaks > 0:
784
+ action = WildfireAction(
785
+ action="break",
786
+ x=random.randint(0, obs.width - 1),
787
+ y=random.randint(0, obs.height - 1)
788
+ )
789
+ else:
790
+ action = WildfireAction(action="wait")
791
+
792
+ result = env.step(action)
793
+ obs = result.observation
794
+ episode_reward += result.reward or 0
795
+ episode_steps += 1
796
+
797
+ episode_rewards.append(episode_reward)
798
+ state = env.state
799
+ print(
800
+ f"Episode {episode + 1}: "
801
+ f"Reward={episode_reward:.2f}, "
802
+ f"Steps={episode_steps}, "
803
+ f"Burned={state.total_burned}, "
804
+ f"Extinguished={state.total_extinguished}"
805
+ )
806
+
807
+ print(f"\nAverage reward: {sum(episode_rewards) / len(episode_rewards):.2f}")
808
+ env.close()
809
+ ```
810
+
811
+ ---
812
+
813
+ ## 🌐 Web Interface
814
+
815
+ The Wildfire Environment includes a **custom web interface** with visual grid display and wildfire-specific features.
816
+
817
+ ### Accessing the Web Interface
818
+
819
+ #### Using Docker
820
+
821
+ ```bash
822
+ # Build base image (first time only)
823
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
824
+
825
+ # Build wildfire environment
826
+ docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
827
+
828
+ # Run container
829
+ docker run -p 8000:8000 -e ENABLE_WEB_INTERFACE=true wildfire-env:latest
830
+ ```
831
+
832
+ Then open: `http://localhost:8000/web`
833
+
834
+ #### Local Testing (No Docker)
835
+
836
+ ```bash
837
+ # Enable web interface with flag
838
+ ENABLE_WEB_INTERFACE=true PYTHONPATH=src uvicorn src.envs.wildfire_env.server.app:app --reload --host 0.0.0.0 --port 8000
839
+ ```
840
+
841
+ ### Web Interface Features
842
+
843
+ #### Left Pane: Action Interface
844
+ - **Wildfire-specific action form**
845
+ - Action dropdown: Water (Extinguish Fire), Break (Create Firebreak), Wait (Do Nothing)
846
+ - Coordinate inputs (X, Y) - auto-populated when clicking grid cells
847
+ - Coordinates show/hide based on action type
848
+ - **Environment stats display**
849
+ - Step count
850
+ - Water remaining
851
+ - Breaks remaining
852
+ - Burning cells count
853
+ - **Current state display**
854
+ - Status (Reset/Running)
855
+ - Episode ID
856
+ - Wind direction
857
+ - Humidity
858
+ - **Control buttons**
859
+ - Reset Environment
860
+ - Get State
861
+
862
+ #### Right Pane: Visual Grid & Logs
863
+ - **Visual 2D Grid Display** 🔥
864
+ - 16×16 grid rendered as color-coded cells
865
+ - **Color coding:**
866
+ - 🟩 **Green** = Fuel (safe, value 1)
867
+ - 🔥 **Orange/Red** = Burning (fire, value 2)
868
+ - ⬛ **Dark Gray** = Ash (burned, value 0)
869
+ - 🟫 **Brown** = Firebreak (value 3)
870
+ - 🟦 **Blue** = Watered/Damp (value 4)
871
+ - **Interactive:** Click cells to set coordinates for water/break actions
872
+ - **Auto-updates:** Grid refreshes automatically via WebSocket
873
+ - **Legend**
874
+ - Color-coded legend explaining all cell types
875
+ - **Action history**
876
+ - Log of all actions with timestamps
877
+ - Shows action, observation, reward, and done status
878
+
879
+ #### Additional Features
880
+ - **WebSocket connection** - Real-time state updates without page refresh
881
+ - **Instructions panel** - Collapsible environment documentation
882
+ - **Grid status indicator** - Shows grid dimensions and cell count
883
+
884
+ ### Using the Web Interface
885
+
886
+ 1. **Start the server** (see above)
887
+ 2. **Open browser** to: `http://localhost:8000/web`
888
+ 3. **Click "Reset Environment"** to initialize and display the grid
889
+ 4. **Interact with the grid:**
890
+ - Click on a cell to set coordinates for water/break actions
891
+ - Or manually enter X, Y coordinates
892
+ 5. **Select action:**
893
+ - Choose `water`, `break`, or `wait` from the dropdown
894
+ 6. **Click "Execute Action"**
895
+ 7. **Watch the grid update in real-time:**
896
+ - Fire spreads automatically
897
+ - Cells change color based on state
898
+ - Stats update automatically
899
+ 8. **Monitor resources** in the stats panel (water, breaks, burning count)
900
+
901
+ ---
902
+
903
+ ## 🔧 Troubleshooting
904
+
905
+ ### Common Issues
906
+
907
+ #### 1. Connection Errors
908
+
909
+ **Problem:** `ConnectionRefusedError` or `Cannot connect to server`
910
+
911
+ **Solutions:**
912
+ - Verify server is running: `curl http://localhost:8000/health`
913
+ - Check Docker container: `docker ps | grep wildfire`
914
+ - Ensure port 8000 is not in use: `lsof -i :8000`
915
+
916
+ #### 2. Index Errors
917
+
918
+ **Problem:** `IndexError: list index out of range`
919
+
920
+ **Solution:** Ensure coordinates are within bounds:
921
+ ```python
922
+ # Always check bounds before accessing
923
+ if 0 <= x < obs.width and 0 <= y < obs.height:
924
+ action = WildfireAction(action="water", x=x, y=y)
925
+ ```
926
+
927
+ #### 3. Invalid Action Warnings
928
+
929
+ **Problem:** Actions returning -0.05 reward repeatedly
930
+
931
+ **Solutions:**
932
+ - Check `remaining_water` and `remaining_breaks` before using resources
933
+ - Verify coordinates are integers and within grid bounds
934
+ - Use `action="wait"` when resources are exhausted
935
+
936
+ #### 4. Grid Format Confusion
937
+
938
+ **Problem:** How to access grid cells?
939
+
940
+ **Solution:**
941
+ ```python
942
+ # Convert flat array to 2D
943
+ grid_2d = np.array(obs.grid).reshape(obs.height, obs.width)
944
+
945
+ # Access cell at (x, y)
946
+ cell_value = grid_2d[y][x]
947
+
948
+ # Or use flat index
949
+ index = y * obs.width + x
950
+ cell_value = obs.grid[index]
951
+ ```
952
+
953
+ #### 5. Docker Build Failures
954
+
955
+ **Problem:** `failed to solve: openenv-base:latest`
956
+
957
+ **Solution:**
958
+ ```bash
959
+ # Build base image first
960
+ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile .
961
+
962
+ # Then build wildfire image
963
+ docker build -t wildfire-env:latest -f src/envs/wildfire_env/server/Dockerfile .
964
+ ```
965
+
966
+ ### Debugging Tips
967
+
968
+ 1. **Enable verbose logging:**
969
+ ```bash
970
+ docker logs -f wildfire-env-container
971
+ ```
972
+
973
+ 2. **Check environment state:**
974
+ ```python
975
+ state = env.state
976
+ print(f"State: {state}")
977
+ ```
978
+
979
+ 3. **Validate actions:**
980
+ ```python
981
+ obs = env.reset().observation
982
+ print(f"Bounds: 0 <= x < {obs.width}, 0 <= y < {obs.height}")
983
+ print(f"Resources: Water={obs.remaining_water}, Breaks={obs.remaining_breaks}")
984
+ ```
985
+
986
+ 4. **Monitor grid changes:**
987
+ ```python
988
+ prev_grid = obs.grid.copy()
989
+ result = env.step(action)
990
+ new_grid = result.observation.grid
991
+ changes = [i for i, (a, b) in enumerate(zip(prev_grid, new_grid)) if a != b]
992
+ print(f"Changed cells: {len(changes)}")
993
+ ```
994
+
995
+ ---
996
+
997
+ ## 📊 Performance Considerations
998
+
999
+ ### Grid Size Impact
1000
+
1001
+ - **Small grids (16×16)**: Fast, good for quick testing
1002
+ - **Medium grids (32×32)**: Default, balanced performance
1003
+ - **Large grids (64×64+)**: Slower, more realistic but requires more compute
1004
+
1005
+ ### Resource Limits
1006
+
1007
+ - **Low water (4-8)**: Forces strategic decisions
1008
+ - **High water (20+)**: More forgiving, easier to succeed
1009
+ - **Low breaks (25)**: Emphasizes firebreak placement strategy
1010
+ - **High breaks (100+)**: More freedom, less constraint
1011
+
1012
+ ### Episode Length
1013
+
1014
+ - **Short episodes (50 steps)**: Fast iteration, good for debugging
1015
+ - **Medium episodes (128 steps)**: Default, balanced
1016
+ - **Long episodes (200+ steps)**: Better for complex strategies
1017
+
1018
+ ---
1019
+
1020
+ ## 🧭 References
1021
+
1022
+ ### Papers & Research
1023
+
1024
+ - **Rothermel Model**: [USDA Forest Service - Surface Fire Spread Model](https://www.fs.fed.us/rm/pubs_series/rmrs/gtr/rmrs_gtr371.pdf)
1025
+ - **SimFire**: [MITRE Fireline Project](https://github.com/mitrefireline/simfire)
1026
+ - **RL for Wildfires**: [arXiv:2311.15925](https://arxiv.org/abs/2311.15925)
1027
+
1028
+ ### OpenEnv Framework
1029
+
1030
+ - **Main Repository**: [OpenEnv GitHub](https://github.com/openenv)
1031
+ - **Documentation**: See `rfcs/` directory for design documents
1032
+ - **Other Environments**: See `src/envs/` for more environment examples
1033
+
1034
+ ### Related Tools
1035
+
1036
+ - **FastAPI**: [FastAPI Documentation](https://fastapi.tiangolo.com/)
1037
+ - **Reinforcement Learning**: [Spinning Up in Deep RL](https://spinningup.openai.com/)
1038
+ - **Docker**: [Docker Documentation](https://docs.docker.com/)
1039
+
1040
+ ---
1041
+
1042
+ ## 📝 License
1043
+
1044
+ This environment is part of the OpenEnv project. See the main LICENSE file for details.
1045
+
1046
+ ---
1047
+
1048
+ ## 🤝 Contributing
1049
+
1050
+ Contributions welcome! Please see `CONTRIBUTING.md` in the main OpenEnv repository.
1051
+
1052
+ ---
1053
+
1054
+ ## 🔖 Citations
1055
+
1056
+ ```bibtex
1057
+ @techreport{rothermel2022surface,
1058
+ title = {The Rothermel Surface Fire Spread Model and Associated Developments},
1059
+ author = {Andrews, Patricia L. and Rothermel, Richard C.},
1060
+ year = {2022},
1061
+ institution = {USDA Forest Service},
1062
+ number = {RMRS-GTR-371},
1063
+ url = {https://www.fs.usda.gov/rm/pubs_series/rmrs/gtr/rmrs_gtr371.pdf}
1064
+ }
1065
+
1066
+ @article{tapley2023reinforcement,
1067
+ title = {Reinforcement Learning for Wildfire Mitigation in Simulated Disaster Environments},
1068
+ author = {Tapley, A. and Dotter, M. and Doyle, M. and others},
1069
+ journal = {arXiv preprint arXiv:2311.15925},
1070
+ year = {2023},
1071
+ url = {https://arxiv.org/abs/2311.15925}
1072
+ }
1073
+
1074
+ @misc{mitrefireline2023simfire,
1075
+ author = {{MITRE Fireline Project}},
1076
+ title = {SimFire: Wildfire Simulator for Decision-Support and AI Research},
1077
+ year = {2023},
1078
+ howpublished = {\url{https://github.com/mitrefireline/simfire}}
1079
+ }
1080
+
1081
+ @misc{wildfire-openenv-2025,
1082
+ title = {Wildfire Environment for OpenEnv: Containment-Focused RL Simulation},
1083
+ author = {OpenEnv Contributors},
1084
+ year = {2025},
1085
+ url = {https://github.com/openenv/openenv}
1086
+ }
1087
+ ```
1088
+
1089
+ ---
1090
+
1091
+ **Happy firefighting! 🔥🚒**
__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from .models import WildfireAction, WildfireObservation, WildfireState
2
+ from .client import WildfireEnv
3
+
4
+ __all__ = [
5
+ "WildfireAction",
6
+ "WildfireObservation",
7
+ "WildfireState",
8
+ "WildfireEnv",
9
+ ]
client.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Support both in-repo and standalone imports
2
+ try:
3
+ # In-repo imports (when running from OpenEnv repository)
4
+ from core.http_env_client import HTTPEnvClient
5
+ from core.client_types import StepResult
6
+ from .models import WildfireAction, WildfireObservation, WildfireState
7
+ except ImportError:
8
+ # Standalone imports (when environment is standalone with openenv-core from pip)
9
+ from openenv_core.http_env_client import HTTPEnvClient
10
+ from openenv_core.client_types import StepResult
11
+ from wildfire_env.models import WildfireAction, WildfireObservation, WildfireState
12
+
13
+ class WildfireEnv(HTTPEnvClient[WildfireAction, WildfireObservation]):
14
+ def _step_payload(self, action: WildfireAction) -> dict:
15
+ return {"action": action.action, "x": action.x, "y": action.y}
16
+
17
+ def _parse_result(self, payload: dict) -> StepResult[WildfireObservation]:
18
+ obs = WildfireObservation(**payload["observation"])
19
+ return StepResult(
20
+ observation=obs,
21
+ reward=payload.get("reward"),
22
+ done=payload.get("done", False),
23
+ )
24
+
25
+ def _parse_state(self, payload: dict) -> WildfireState:
26
+ return WildfireState(**payload)
27
+
28
+
29
+ def render_grid(obs: WildfireObservation) -> str:
30
+ legend = {0:"⬛", 1:"🟩", 2:"🟥", 3:"🟫", 4:"🟦"}
31
+ w, h = obs.width, obs.height
32
+ g = obs.grid
33
+ rows = []
34
+ for y in range(h):
35
+ rows.append("".join(legend.get(g[y*w+x], "?") for x in range(w)))
36
+ meta = f"step={obs.step} wind={obs.wind_dir} hum={obs.humidity:.2f} burning={obs.burning_count} burned={obs.burned_count}"
37
+ return "\n".join(rows + [meta])
models.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+ # Support both in-repo and standalone imports
5
+ try:
6
+ # In-repo imports (when running from OpenEnv repository)
7
+ from openenv.core.env_server import Action, Observation, State
8
+ except ImportError:
9
+ # Standalone imports (when environment is standalone with openenv-core from pip)
10
+ from openenv_core.env_server import Action, Observation, State
11
+
12
+ # Grid cell encoding:
13
+ # 0 = empty/ash, 1 = fuel (healthy), 2 = burning, 3 = firebreak, 4 = watered (damp)
14
+ # (You can tweak encodings, but keep them ints for compact obs.)
15
+
16
+ @dataclass
17
+ class WildfireAction(Action):
18
+ # action: "break" (build firebreak), "water" (drop water), "wait"
19
+ action: str
20
+ x: Optional[int] = None
21
+ y: Optional[int] = None
22
+
23
+ @dataclass
24
+ class WildfireObservation(Observation):
25
+ grid: List[int] # flattened grid H*W, ints in {0..4}
26
+ width: int
27
+ height: int
28
+ step: int
29
+ wind_dir: str # e.g. "N","NE","E","SE","S","SW","W","NW","CALM"
30
+ humidity: float # [0,1]
31
+ burning_count: int
32
+ burned_count: int # total ash (0) cells (cumulative)
33
+ reward_hint: float = 0.0 # optional shaping info
34
+ remaining_water: int = 0
35
+ remaining_breaks: int = 0
36
+
37
+ @dataclass
38
+ class WildfireState(State):
39
+ episode_id: str = ""
40
+ step_count: int = 0
41
+ total_burned: int = 0
42
+ total_extinguished: int = 0
43
+ last_action: str = "reset"
44
+ # For visibility / debugging (not required by core):
45
+ width: int = 0
46
+ height: int = 0
47
+ wind_dir: str = "CALM"
48
+ humidity: float = 0.25
49
+ remaining_water: int = 20 # simple resource constraint
50
+ remaining_breaks: int = 50
51
+ # internal full grid as flattened ints
52
+ grid: List[int] = field(default_factory=list)
53
+ # burn timers for each cell (track how long cells have been burning/damp)
54
+ burn_timers: List[int] = field(default_factory=list)
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ name: wildfire_env
2
+ version: "0.1.0"
3
+ description: "Wildfire containment environment for OpenEnv"
4
+ action: WildfireAction
5
+ observation: WildfireObservation
6
+
pyproject.toml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-wildfire-env"
7
+ version = "0.1.0"
8
+ description = "Wildfire Environment for OpenEnv - autonomous wildfire-control simulation for reinforcement learning"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.0",
12
+ "fastapi>=0.115.0",
13
+ "pydantic>=2.0.0",
14
+ "uvicorn[standard]>=0.24.0",
15
+ "requests>=2.31.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0.0",
21
+ "pytest-cov>=4.0.0",
22
+ "ipykernel>=6.29.5",
23
+ "matplotlib>=3.7.0",
24
+ "numpy>=1.24.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ server = "wildfire_env.server.app:main"
29
+
30
+
31
+ [tool.setuptools]
32
+ packages = ["wildfire_env", "wildfire_env.server"]
33
+ package-dir = {"wildfire_env" = ".", "wildfire_env.server" = "server"}
34
+
run_server.sh ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Run the wildfire environment server from the monorepo
3
+
4
+ # Get the OpenEnv root directory (3 levels up from this script)
5
+ OPENENV_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
6
+
7
+ # Run from monorepo root with proper PYTHONPATH
8
+ cd "$OPENENV_ROOT"
9
+ PYTHONPATH=src python -m envs.wildfire_env.server.app "$@"
server/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Arizona State University and contributors.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license
5
+ # found in the LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Wildfire Environment Server.
9
+
10
+ Server-side implementation of the wildfire environment for OpenEnv.
11
+ """
12
+
13
+ from .wildfire_environment import WildfireEnvironment
14
+
15
+ __all__ = ["WildfireEnvironment"]
server/app.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # server/app.py
2
+ import os
3
+ from fastapi.responses import HTMLResponse
4
+ from fastapi import WebSocket, WebSocketDisconnect
5
+ from dataclasses import asdict
6
+
7
+ # Support both in-repo and standalone imports
8
+ try:
9
+ # In-repo imports (when running from OpenEnv repository)
10
+ from openenv.core.env_server import create_fastapi_app
11
+ from openenv.core.env_server.web_interface import load_environment_metadata, WebInterfaceManager
12
+ from openenv.core.env_server.types import Action, Observation
13
+ from ..models import WildfireAction, WildfireObservation
14
+ from .wildfire_environment import WildfireEnvironment
15
+ from .wildfire_web_interface import get_wildfire_web_interface_html
16
+ except ImportError:
17
+ # Standalone imports (when environment is standalone with openenv-core from pip)
18
+ from openenv_core.env_server import create_fastapi_app
19
+ from openenv_core.env_server.web_interface import load_environment_metadata, WebInterfaceManager
20
+ from openenv_core.env_server.types import Action, Observation
21
+ from wildfire_env.models import WildfireAction, WildfireObservation
22
+ from wildfire_env.server.wildfire_environment import WildfireEnvironment
23
+ from wildfire_env.server.wildfire_web_interface import get_wildfire_web_interface_html
24
+
25
+ W = int(os.getenv("WILDFIRE_WIDTH", "16"))
26
+ H = int(os.getenv("WILDFIRE_HEIGHT", "16"))
27
+ env = WildfireEnvironment(width=W, height=H)
28
+
29
+ # Create base app without web interface
30
+ app = create_fastapi_app(env, WildfireAction, WildfireObservation)
31
+
32
+ # Check if web interface should be enabled
33
+ # This can be controlled via environment variable
34
+ enable_web = (
35
+ os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes")
36
+ )
37
+
38
+ if enable_web:
39
+ # Load environment metadata
40
+ metadata = load_environment_metadata(env, 'wildfire_env')
41
+
42
+ # Create web interface manager (needed for /web/reset, /web/step, /ws endpoints)
43
+ web_manager = WebInterfaceManager(env, WildfireAction, WildfireObservation, metadata)
44
+
45
+ # Add our custom wildfire interface route
46
+ @app.get("/web", response_class=HTMLResponse)
47
+ async def wildfire_web_interface():
48
+ """Custom wildfire-specific web interface."""
49
+ return get_wildfire_web_interface_html(metadata)
50
+
51
+ # Add web interface endpoints (these are needed for the interface to work)
52
+ @app.get("/web/metadata")
53
+ async def web_metadata():
54
+ """Get environment metadata."""
55
+ return asdict(metadata)
56
+
57
+ @app.websocket("/ws")
58
+ async def websocket_endpoint(websocket: WebSocket):
59
+ """WebSocket endpoint for real-time updates."""
60
+ await web_manager.connect_websocket(websocket)
61
+ try:
62
+ while True:
63
+ # Keep connection alive
64
+ await websocket.receive_text()
65
+ except WebSocketDisconnect:
66
+ await web_manager.disconnect_websocket(websocket)
67
+
68
+ @app.post("/web/reset")
69
+ async def web_reset():
70
+ """Reset endpoint for web interface."""
71
+ return await web_manager.reset_environment()
72
+
73
+ @app.post("/web/step")
74
+ async def web_step(request: dict):
75
+ """Step endpoint for web interface."""
76
+ action_data = request.get("action", {})
77
+ return await web_manager.step_environment(action_data)
78
+
79
+ @app.get("/web/state")
80
+ async def web_state():
81
+ """State endpoint for web interface."""
82
+ return web_manager.get_state()
83
+
84
+
85
+ def main():
86
+ """Main entry point for running the server."""
87
+ import uvicorn
88
+ port = int(os.getenv("PORT", "8000"))
89
+ uvicorn.run(app, host="0.0.0.0", port=port)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
server/build_docker.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ TAG="${1:-latest}"
5
+ IMAGE_NAME="wildfire-env:${TAG}"
6
+
7
+ echo "🔥 Building Wildfire Environment Docker Image"
8
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
9
+ OPENENV_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
10
+
11
+ docker build \
12
+ -f "$SCRIPT_DIR/Dockerfile" \
13
+ -t "$IMAGE_NAME" \
14
+ "$OPENENV_ROOT"
server/wildfire_environment.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import random
4
+ import uuid
5
+
6
+ # Support both in-repo and standalone imports
7
+ try:
8
+ # In-repo imports (when running from OpenEnv repository)
9
+ from openenv.core.env_server import Environment
10
+ from ..models import WildfireAction, WildfireObservation, WildfireState
11
+ except ImportError:
12
+ # Standalone imports (when environment is standalone with openenv-core from pip)
13
+ from openenv_core.env_server import Environment
14
+ from wildfire_env.models import WildfireAction, WildfireObservation, WildfireState
15
+
16
+ # Helpers
17
+ DIRS_8 = {
18
+ "N": (0, -1), "NE": (1, -1), "E": (1, 0), "SE": (1, 1),
19
+ "S": (0, 1), "SW": (-1, 1), "W": (-1, 0), "NW": (-1, -1),
20
+ "CALM": (0, 0),
21
+ }
22
+
23
+ def idx(x: int, y: int, w: int) -> int:
24
+ # Defensive type conversion to ensure all parameters are integers
25
+ x, y, w = int(x), int(y), int(w)
26
+ return y * w + x
27
+
28
+ def in_bounds(x: int, y: int, w: int, h: int) -> bool:
29
+ # Defensive type conversion to ensure all parameters are integers
30
+ x, y, w, h = int(x), int(y), int(w), int(h)
31
+ return 0 <= x < w and 0 <= y < h
32
+
33
+
34
+ class WildfireEnvironment(Environment):
35
+ """
36
+ Weather-aware wildfire simulation.
37
+
38
+ Grid encodings:
39
+ 0 = ash (burned out)
40
+ 1 = fuel / vegetation
41
+ 2 = burning
42
+ 3 = firebreak
43
+ 4 = watered / damp
44
+
45
+ Each step:
46
+ - agent acts (water/break/wait)
47
+ - burning spreads to neighbors with wind + humidity effects
48
+ - burning cells burn for multiple ticks, then become ash
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ width: int = 32,
54
+ height: int = 32,
55
+ base_ignite_prob: float = 0.30,
56
+ wind_bias: float = 0.20, # kept for compatibility (not directly used in B model)
57
+ diag_factor: float = 0.7, # kept for compatibility (not directly used in B model)
58
+ humidity: float = 0.25,
59
+ init_sources: int = 2,
60
+ seed: int = 3407,
61
+ max_steps: int = 128,
62
+ water_capacity: int = 8, # ↓ encourage strategic water use
63
+ break_capacity: int = 50,
64
+ ):
65
+ super().__init__()
66
+
67
+ # --- Env-var overrides (optional) ---
68
+ width = int(os.environ.get("WILDFIRE_WIDTH", width))
69
+ height = int(os.environ.get("WILDFIRE_HEIGHT", height))
70
+ humidity = float(os.environ.get("WILDFIRE_HUMIDITY", humidity))
71
+ forced_wind = os.environ.get("WILDFIRE_WIND", None)
72
+
73
+ # Store config (ensure integers)
74
+ self.w = int(width)
75
+ self.h = int(height)
76
+ self.base_ignite_prob = base_ignite_prob
77
+ self.wind_bias = wind_bias
78
+ self.diag_factor = diag_factor
79
+ self.init_humidity = humidity
80
+ self.init_sources = init_sources
81
+ self.rng = random.Random(seed)
82
+ self.max_steps = max_steps
83
+ self.init_water = water_capacity
84
+ self.init_breaks = break_capacity
85
+ self.forced_wind = forced_wind
86
+
87
+ # burn lifetime in ticks (balanced model)
88
+ self.burn_lifetime = 3
89
+
90
+ self._state = WildfireState()
91
+
92
+ # --- Core API ---
93
+
94
+ def reset(self) -> WildfireObservation:
95
+ # Ensure w and h are integers (defensive type conversion)
96
+ w, h = int(self.w), int(self.h)
97
+
98
+ # Start with all fuel
99
+ grid = [1] * (w * h)
100
+
101
+ # Wind (forced if provided)
102
+ if self.forced_wind and self.forced_wind in DIRS_8:
103
+ wind_dir = self.forced_wind
104
+ else:
105
+ wind_dir = self.rng.choice(list(DIRS_8.keys()))
106
+
107
+ # Humidity small variation around init
108
+ humidity = min(1.0, max(0.0, self.init_humidity + self.rng.uniform(-0.05, 0.05)))
109
+
110
+ # Place initial fires
111
+ for _ in range(self.init_sources):
112
+ x = self.rng.randrange(w)
113
+ y = self.rng.randrange(h)
114
+ i = idx(x, y, w)
115
+ # Safety check: ensure index is within grid bounds
116
+ if 0 <= i < len(grid):
117
+ grid[i] = 2
118
+
119
+ self._state = WildfireState(
120
+ episode_id=str(uuid.uuid4()),
121
+ step_count=0,
122
+ total_burned=0,
123
+ total_extinguished=0,
124
+ last_action="reset",
125
+ width=w,
126
+ height=h,
127
+ wind_dir=wind_dir,
128
+ humidity=humidity,
129
+ remaining_water=self.init_water,
130
+ remaining_breaks=self.init_breaks,
131
+ grid=grid,
132
+ )
133
+
134
+ # per-cell burn timers (persist across steps)
135
+ self._state.burn_timers = [0] * (w * h)
136
+
137
+ obs = self._make_observation(reward_hint=0.0)
138
+ return obs
139
+
140
+ def step(self, action: WildfireAction) -> WildfireObservation:
141
+ st = self._state
142
+ reward = 0.0
143
+
144
+ # --- Agent action effects ---
145
+ if (
146
+ action.action == "water"
147
+ and st.remaining_water > 0
148
+ and action.x is not None
149
+ and action.y is not None
150
+ ):
151
+ reward += self._apply_water(action.x, action.y)
152
+ elif (
153
+ action.action == "break"
154
+ and st.remaining_breaks > 0
155
+ and action.x is not None
156
+ and action.y is not None
157
+ ):
158
+ reward += self._apply_break(action.x, action.y)
159
+ elif action.action == "wait":
160
+ pass
161
+ else:
162
+ reward -= 0.05 # invalid or exhausted resources
163
+
164
+ # --- Natural fire dynamics ---
165
+ prev_burning = self._burning_count()
166
+ prev_burned = sum(1 for v in st.grid if v == 0)
167
+
168
+ newly_burned = self._spread_fire()
169
+ new_burning = self._burning_count()
170
+ now_burned = sum(1 for v in st.grid if v == 0)
171
+
172
+ st.total_burned += newly_burned
173
+ st.step_count += 1
174
+ st.last_action = action.action
175
+
176
+ # --- Spread vs containment shaping ---
177
+ spread_delta = new_burning - prev_burning
178
+ burned_delta = now_burned - prev_burned
179
+
180
+ # Strong penalty for spread
181
+ if spread_delta > 0:
182
+ reward -= 0.15 * spread_delta # 🔥 focus on containment
183
+ elif spread_delta < 0:
184
+ reward += 0.10 * abs(spread_delta) # reward shrinkage
185
+
186
+ # Mild penalty for newly burned cells (area loss)
187
+ if burned_delta > 0:
188
+ reward -= 0.05 * burned_delta
189
+
190
+ # Small time penalty to prefer fast control
191
+ reward -= 0.01
192
+
193
+ done = self._is_done()
194
+
195
+ # --- End of episode bonuses ---
196
+ if done:
197
+ saved_ratio = self._saved_cells() / (self.w * self.h)
198
+ burned_ratio = now_burned / (self.w * self.h)
199
+ burning_left = self._burning_count()
200
+
201
+ # Big containment bonus
202
+ if burning_left == 0:
203
+ reward += 0.5 + 0.5 * saved_ratio
204
+
205
+ # Fallback proportional reward
206
+ reward += 0.2 * (1.0 - burned_ratio)
207
+
208
+ obs = self._make_observation(reward_hint=reward)
209
+ obs.done = done
210
+ obs.reward = reward
211
+ return obs
212
+
213
+
214
+ # --- Internal mechanics ---
215
+
216
+ def _apply_water(self, x: int, y: int) -> float:
217
+ st = self._state
218
+ # Ensure x and y are integers (defensive type conversion)
219
+ x, y = int(x), int(y)
220
+ if not in_bounds(x, y, self.w, self.h):
221
+ return -0.05
222
+
223
+ # Strong penalty if no water left
224
+ if st.remaining_water <= 0:
225
+ return -0.5
226
+
227
+ i = idx(x, y, self.w)
228
+ # Safety check: ensure index is within grid bounds
229
+ if i < 0 or i >= len(st.grid):
230
+ return -0.05
231
+
232
+ reward = 0.0
233
+
234
+ if st.grid[i] == 2:
235
+ st.grid[i] = 4 # extinguish & dampen
236
+ st.burn_timers[i] = 0
237
+ st.total_extinguished += 1
238
+ reward += 0.25
239
+ elif st.grid[i] == 1:
240
+ st.grid[i] = 4 # dampen fuel (mild penalty to avoid spamming)
241
+ st.burn_timers[i] = 0
242
+ reward -= 0.10
243
+ elif st.grid[i] == 4:
244
+ # redundant watering
245
+ reward -= 0.05
246
+ else:
247
+ # watering ash/break gives slight penalty
248
+ reward -= 0.05
249
+
250
+ st.remaining_water -= 1
251
+ return reward
252
+
253
+ def _apply_break(self, x: int, y: int) -> float:
254
+ st = self._state
255
+ # Ensure x and y are integers (defensive type conversion)
256
+ x, y = int(x), int(y)
257
+ if not in_bounds(x, y, self.w, self.h):
258
+ return -0.05
259
+ i = idx(x, y, self.w)
260
+ # Safety check: ensure index is within grid bounds
261
+ if i < 0 or i >= len(st.grid):
262
+ return -0.05
263
+
264
+ reward = 0.0
265
+
266
+ if st.grid[i] in (1, 4):
267
+ st.grid[i] = 3
268
+ st.burn_timers[i] = 0
269
+ reward += 0.15 # slightly more than before to make firebreaks attractive
270
+ elif st.grid[i] == 2:
271
+ st.grid[i] = 3
272
+ st.burn_timers[i] = 0
273
+ reward -= 0.02
274
+ elif st.grid[i] == 3:
275
+ reward -= 0.01
276
+ else:
277
+ reward -= 0.02
278
+
279
+ st.remaining_breaks -= 1
280
+ return reward
281
+
282
+ def _spread_fire(self) -> int:
283
+ """
284
+ Balanced wildfire spread model:
285
+ - burning cells persist for multiple ticks before turning to ash
286
+ - 8-direction spread (diagonals weaker)
287
+ - wind accelerates in wind direction, weakens upwind
288
+ - humidity suppresses ignition probability
289
+ - water (4) is IMMUNE to ignition while damp and reverts to fuel after several ticks
290
+ """
291
+ st = self._state
292
+ new_grid = st.grid[:]
293
+ newly_burned = 0
294
+
295
+ # Ensure w and h are integers (defensive type conversion)
296
+ w, h = int(self.w), int(self.h)
297
+
298
+ # 8-neighbor model
299
+ neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1),
300
+ (-1, -1), (1, -1), (-1, 1), (1, 1)]
301
+ wx, wy = DIRS_8.get(st.wind_dir, (0, 0))
302
+
303
+ base = self.base_ignite_prob
304
+ humidity_factor = (1.0 - st.humidity)
305
+
306
+ ignite_flags = [False] * (w * h)
307
+
308
+ # First pass: evaluate ignitions, increment burn timers
309
+ for y in range(h):
310
+ for x in range(w):
311
+ i = idx(x, y, w)
312
+ # Safety check: ensure index is within grid bounds
313
+ if i < 0 or i >= len(st.grid):
314
+ continue
315
+ cell = st.grid[i]
316
+
317
+ if cell == 2: # burning
318
+ st.burn_timers[i] += 1
319
+
320
+ for dx, dy in neighbors:
321
+ nx, ny = x + dx, y + dy
322
+ if not in_bounds(nx, ny, w, h):
323
+ continue
324
+ ni = idx(nx, ny, w)
325
+ # Safety check: ensure neighbor index is within grid bounds
326
+ if ni < 0 or ni >= len(st.grid):
327
+ continue
328
+ target = st.grid[ni]
329
+
330
+ # Only fuel or water/damp can be candidates, but cells with code 4 (watered/damp) are immune to ignition
331
+ if target == 4:
332
+ # Watered/damp cells (code 4) do not ignite at all while in this state
333
+ continue
334
+ if target != 1:
335
+ continue
336
+
337
+ # Wind multiplier
338
+ if (dx, dy) == (wx, wy):
339
+ wind_mult = 2.0
340
+ elif (dx, dy) == (-wx, -wy):
341
+ wind_mult = 0.5
342
+ else:
343
+ wind_mult = 1.0
344
+
345
+ # Diagonals weaker
346
+ diag_mult = 0.6 if (dx != 0 and dy != 0) else 1.0
347
+
348
+ p = base * humidity_factor * wind_mult * diag_mult
349
+ p = max(0.0, min(1.0, p))
350
+ if self.rng.random() < p:
351
+ # Safety check: ensure ni is within ignite_flags bounds
352
+ if 0 <= ni < len(ignite_flags):
353
+ ignite_flags[ni] = True
354
+
355
+ # Second pass: apply transitions
356
+ for i, cell in enumerate(st.grid):
357
+ # Safety check: ensure index is within bounds for all arrays
358
+ if i < 0 or i >= len(new_grid) or i >= len(st.burn_timers):
359
+ continue
360
+
361
+ if cell == 2:
362
+ # burns for burn_lifetime ticks before turning to ash
363
+ if st.burn_timers[i] >= self.burn_lifetime:
364
+ new_grid[i] = 0 # ash
365
+ newly_burned += 1
366
+ else:
367
+ new_grid[i] = 2 # keep burning
368
+ elif i < len(ignite_flags) and ignite_flags[i] and new_grid[i] == 1:
369
+ new_grid[i] = 2
370
+ st.burn_timers[i] = 0
371
+ elif cell == 4:
372
+ # Water stays damp for several ticks before reverting to fuel
373
+ st.burn_timers[i] += 1
374
+ if st.burn_timers[i] >= 6: # was 3; extend to make water useful
375
+ new_grid[i] = 1
376
+
377
+ st.grid = new_grid
378
+ return newly_burned
379
+
380
+ def _burning_count(self) -> int:
381
+ return sum(1 for v in self._state.grid if v == 2)
382
+
383
+ def _saved_cells(self) -> int:
384
+ # cells not turned to ash (includes fuel, burning, break, water)
385
+ return sum(1 for v in self._state.grid if v in (1, 2, 3, 4))
386
+
387
+ def _is_done(self) -> bool:
388
+ return self._burning_count() == 0 or self._state.step_count >= self.max_steps
389
+
390
+ def _make_observation(self, reward_hint: float = 0.0) -> WildfireObservation:
391
+ st = self._state
392
+ burning = self._burning_count()
393
+ burned = sum(1 for v in st.grid if v == 0)
394
+ return WildfireObservation(
395
+ grid=st.grid[:],
396
+ width=self.w,
397
+ height=self.h,
398
+ step=st.step_count,
399
+ wind_dir=st.wind_dir,
400
+ humidity=st.humidity,
401
+ burning_count=burning,
402
+ remaining_water=st.remaining_water, # ✅ new
403
+ remaining_breaks=st.remaining_breaks, # ✅ new
404
+ burned_count=burned,
405
+ reward_hint=reward_hint,
406
+ )
407
+
408
+ # --- Required abstract property implementation ---
409
+ @property
410
+ def state(self) -> WildfireState:
411
+ """Return the current environment state."""
412
+ return self._state
413
+
server/wildfire_web_interface.py ADDED
@@ -0,0 +1,1022 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom web interface for Wildfire Environment.
3
+
4
+ This module provides a wildfire-specific web interface with visual grid display
5
+ and wildfire-specific features, without modifying the base web_interface.py.
6
+ """
7
+
8
+ from typing import Optional
9
+ import json
10
+ from dataclasses import asdict
11
+
12
+ # Support both in-repo and standalone imports
13
+ try:
14
+ # In-repo imports (when running from OpenEnv repository)
15
+ from core.env_server.types import EnvironmentMetadata
16
+ from ..models import WildfireAction
17
+ except ImportError:
18
+ # Standalone imports (when environment is standalone with openenv-core from pip)
19
+ from openenv_core.env_server.types import EnvironmentMetadata
20
+ from wildfire_env.models import WildfireAction
21
+
22
+
23
+ def get_wildfire_web_interface_html(metadata: Optional[EnvironmentMetadata] = None) -> str:
24
+ """Generate custom HTML for the wildfire environment web interface."""
25
+
26
+ # Prepare README markdown and a simple HTML fallback
27
+ instructions_html = ""
28
+ instructions_json = "null"
29
+ if metadata and metadata.readme_content:
30
+ # Fallback lightweight conversion (in case JS markdown library fails)
31
+ instructions_html = _markdown_to_html_simple(metadata.readme_content)
32
+ # Primary: pass raw markdown to the client for proper rendering via marked.js
33
+ instructions_json = json.dumps(metadata.readme_content)
34
+
35
+ return f"""
36
+ <!DOCTYPE html>
37
+ <html lang="en">
38
+ <head>
39
+ <meta charset="UTF-8">
40
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
41
+ <title>Wildfire Environment - Web Interface</title>
42
+ <!-- Markdown rendering libraries -->
43
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
45
+ <script>
46
+ // Embed raw README markdown for client-side rendering
47
+ window.__WILDFIRE_README__ = {instructions_json};
48
+ </script>
49
+ <style>
50
+ * {{
51
+ margin: 0;
52
+ padding: 0;
53
+ box-sizing: border-box;
54
+ }}
55
+
56
+ body {{
57
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
58
+ background-color: #f5f5f5;
59
+ height: 100vh;
60
+ overflow: hidden;
61
+ }}
62
+
63
+ .container {{
64
+ display: flex;
65
+ height: 100vh;
66
+ }}
67
+
68
+ .left-pane {{
69
+ width: 50%;
70
+ background: white;
71
+ border-right: 1px solid #e0e0e0;
72
+ display: flex;
73
+ flex-direction: column;
74
+ }}
75
+
76
+ .right-pane {{
77
+ width: 50%;
78
+ background: #fafafa;
79
+ display: flex;
80
+ flex-direction: column;
81
+ }}
82
+
83
+ .pane-header {{
84
+ padding: 20px;
85
+ border-bottom: 1px solid #e0e0e0;
86
+ background: #f8f9fa;
87
+ font-weight: 600;
88
+ font-size: 16px;
89
+ }}
90
+
91
+ .pane-content {{
92
+ flex: 1;
93
+ padding: 20px;
94
+ overflow-y: auto;
95
+ }}
96
+
97
+ /* Action Form Styles */
98
+ .action-form {{
99
+ background: white;
100
+ border: 1px solid #e0e0e0;
101
+ border-radius: 8px;
102
+ padding: 20px;
103
+ margin-bottom: 20px;
104
+ }}
105
+
106
+ .form-group {{
107
+ margin-bottom: 15px;
108
+ }}
109
+
110
+ .form-group label {{
111
+ display: block;
112
+ margin-bottom: 5px;
113
+ font-weight: 500;
114
+ color: #333;
115
+ }}
116
+
117
+ .form-group select, .form-group input {{
118
+ width: 100%;
119
+ padding: 8px 12px;
120
+ border: 1px solid #ddd;
121
+ border-radius: 4px;
122
+ font-size: 14px;
123
+ }}
124
+
125
+ .form-group select:focus, .form-group input:focus {{
126
+ outline: none;
127
+ border-color: #007bff;
128
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
129
+ }}
130
+
131
+ /* Buttons */
132
+ .btn {{
133
+ background: #007bff;
134
+ color: white;
135
+ border: none;
136
+ padding: 10px 20px;
137
+ border-radius: 4px;
138
+ cursor: pointer;
139
+ font-size: 14px;
140
+ margin-right: 10px;
141
+ margin-bottom: 10px;
142
+ }}
143
+
144
+ .btn:hover {{
145
+ background: #0056b3;
146
+ }}
147
+
148
+ .btn:disabled {{
149
+ background: #6c757d;
150
+ cursor: not-allowed;
151
+ }}
152
+
153
+ .btn-secondary {{
154
+ background: #6c757d;
155
+ }}
156
+
157
+ .btn-secondary:hover {{
158
+ background: #545b62;
159
+ }}
160
+
161
+ /* Grid Visualization */
162
+ .grid-container {{
163
+ background: white;
164
+ border: 1px solid #e0e0e0;
165
+ border-radius: 8px;
166
+ padding: 20px;
167
+ margin-bottom: 20px;
168
+ }}
169
+
170
+ .grid-display {{
171
+ display: inline-block;
172
+ border: 2px solid #333;
173
+ background: #fff;
174
+ padding: 5px;
175
+ margin: 10px 0;
176
+ }}
177
+
178
+ .grid {{
179
+ display: grid;
180
+ gap: 1px;
181
+ background: #333;
182
+ }}
183
+
184
+ .cell {{
185
+ width: 20px;
186
+ height: 20px;
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ font-size: 10px;
191
+ cursor: pointer;
192
+ position: relative;
193
+ }}
194
+
195
+ .cell.ash {{ background-color: #2f2f2f; }}
196
+ .cell.fuel {{ background-color: #228b22; }}
197
+ .cell.burning {{ background-color: #ff4500; }}
198
+ .cell.firebreak {{ background-color: #8b4513; }}
199
+ .cell.watered {{ background-color: #4169e1; }}
200
+
201
+ .cell:hover {{
202
+ opacity: 0.8;
203
+ transform: scale(1.1);
204
+ z-index: 10;
205
+ }}
206
+
207
+ /* Stats Display */
208
+ .stats-display {{
209
+ background: white;
210
+ border: 1px solid #e0e0e0;
211
+ border-radius: 8px;
212
+ padding: 15px;
213
+ margin-bottom: 20px;
214
+ }}
215
+
216
+ .stats-grid {{
217
+ display: grid;
218
+ grid-template-columns: repeat(2, 1fr);
219
+ gap: 15px;
220
+ margin-top: 10px;
221
+ }}
222
+
223
+ .stat-item {{
224
+ display: flex;
225
+ flex-direction: column;
226
+ }}
227
+
228
+ .stat-label {{
229
+ font-size: 12px;
230
+ color: #666;
231
+ margin-bottom: 5px;
232
+ }}
233
+
234
+ .stat-value {{
235
+ font-size: 20px;
236
+ font-weight: bold;
237
+ color: #007bff;
238
+ }}
239
+
240
+ /* Instructions Section */
241
+ .instructions-section {{
242
+ background: white;
243
+ border: 1px solid #e0e0e0;
244
+ border-radius: 8px;
245
+ padding: 20px;
246
+ margin-bottom: 20px;
247
+ }}
248
+
249
+ .instructions-header {{
250
+ display: flex;
251
+ justify-content: space-between;
252
+ align-items: center;
253
+ margin-bottom: 15px;
254
+ }}
255
+
256
+ .instructions-title {{
257
+ font-size: 18px;
258
+ font-weight: 600;
259
+ color: #333;
260
+ margin: 0;
261
+ }}
262
+
263
+ .instructions-toggle {{
264
+ background: #f8f9fa;
265
+ border: 1px solid #dee2e6;
266
+ border-radius: 4px;
267
+ padding: 5px 10px;
268
+ cursor: pointer;
269
+ font-size: 12px;
270
+ color: #6c757d;
271
+ }}
272
+
273
+ .instructions-toggle:hover {{
274
+ background: #e9ecef;
275
+ }}
276
+
277
+ .instructions-content {{
278
+ display: none;
279
+ max-height: 400px;
280
+ overflow-y: auto;
281
+ border-top: 1px solid #e0e0e0;
282
+ padding-top: 15px;
283
+ }}
284
+
285
+ .instructions-content.expanded {{
286
+ display: block;
287
+ }}
288
+
289
+ /* Legend */
290
+ .legend {{
291
+ background: white;
292
+ border: 1px solid #e0e0e0;
293
+ border-radius: 8px;
294
+ padding: 15px;
295
+ margin-bottom: 20px;
296
+ }}
297
+
298
+ .legend-items {{
299
+ display: flex;
300
+ flex-wrap: wrap;
301
+ gap: 15px;
302
+ margin-top: 10px;
303
+ }}
304
+
305
+ .legend-item {{
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 8px;
309
+ }}
310
+
311
+ .legend-color {{
312
+ width: 20px;
313
+ height: 20px;
314
+ border: 1px solid #333;
315
+ }}
316
+
317
+ /* Connection Status */
318
+ .status-indicator {{
319
+ display: inline-block;
320
+ width: 8px;
321
+ height: 8px;
322
+ border-radius: 50%;
323
+ margin-right: 8px;
324
+ }}
325
+
326
+ .status-connected {{
327
+ background: #28a745;
328
+ }}
329
+
330
+ .status-disconnected {{
331
+ background: #dc3545;
332
+ }}
333
+
334
+ /* Action Logs */
335
+ .logs-container {{
336
+ background: white;
337
+ border: 1px solid #e0e0e0;
338
+ border-radius: 8px;
339
+ padding: 15px;
340
+ max-height: 300px;
341
+ overflow-y: auto;
342
+ }}
343
+
344
+ .log-entry {{
345
+ border-bottom: 1px solid #f0f0f0;
346
+ padding: 10px 0;
347
+ }}
348
+
349
+ .log-entry:last-child {{
350
+ border-bottom: none;
351
+ }}
352
+
353
+ .log-timestamp {{
354
+ font-size: 12px;
355
+ color: #666;
356
+ margin-bottom: 5px;
357
+ }}
358
+
359
+ .log-action {{
360
+ background: #e3f2fd;
361
+ padding: 8px;
362
+ border-radius: 4px;
363
+ margin-bottom: 5px;
364
+ font-family: monospace;
365
+ font-size: 12px;
366
+ }}
367
+
368
+ .log-reward {{
369
+ font-weight: 600;
370
+ color: #28a745;
371
+ }}
372
+
373
+ .log-done {{
374
+ font-weight: 600;
375
+ color: #dc3545;
376
+ }}
377
+
378
+ /* State Display */
379
+ .state-display {{
380
+ background: white;
381
+ border: 1px solid #e0e0e0;
382
+ border-radius: 8px;
383
+ padding: 15px;
384
+ margin-bottom: 20px;
385
+ }}
386
+
387
+ .state-item {{
388
+ margin-bottom: 8px;
389
+ }}
390
+
391
+ .state-label {{
392
+ font-weight: 500;
393
+ color: #666;
394
+ }}
395
+
396
+ .state-value {{
397
+ color: #333;
398
+ font-family: monospace;
399
+ }}
400
+ </style>
401
+ </head>
402
+ <body>
403
+ <div class="container">
404
+ <!-- Left Pane: Action Interface -->
405
+ <div class="left-pane">
406
+ <div class="pane-header">
407
+ <span class="status-indicator status-disconnected" id="connection-status"></span>
408
+ Wildfire Containment Interface
409
+ </div>
410
+ <div class="pane-content">
411
+ <!-- Instructions Section -->
412
+ {_generate_instructions_section(instructions_html, metadata)}
413
+
414
+ <!-- Action Form -->
415
+ <div class="action-form">
416
+ <h3>Take Action</h3>
417
+ <form id="action-form">
418
+ <div class="form-group">
419
+ <label for="action">Action Type <span style="color: red;">*</span></label>
420
+ <select name="action" id="action" required>
421
+ <option value="">-- Select Action --</option>
422
+ <option value="water">Water (Extinguish Fire)</option>
423
+ <option value="break">Break (Create Firebreak)</option>
424
+ <option value="wait">Wait (Do Nothing)</option>
425
+ </select>
426
+ <small style="display: block; margin-top: 5px; color: #666;">
427
+ Water: Extinguishes fire at target cell<br>
428
+ Break: Creates firebreak to prevent spread<br>
429
+ Wait: Fire continues spreading
430
+ </small>
431
+ </div>
432
+
433
+ <div class="form-group" id="coordinates-group" style="display: none;">
434
+ <label for="x">X Coordinate</label>
435
+ <input type="number" name="x" id="x" min="0" placeholder="Enter X coordinate">
436
+
437
+ <label for="y" style="margin-top: 10px;">Y Coordinate</label>
438
+ <input type="number" name="y" id="y" min="0" placeholder="Enter Y coordinate">
439
+ <small style="display: block; margin-top: 5px; color: #666;">
440
+ Coordinates are required for water and break actions
441
+ </small>
442
+ </div>
443
+
444
+ <button type="submit" class="btn" id="step-btn">Execute Action</button>
445
+ </form>
446
+ </div>
447
+
448
+ <!-- Control Buttons -->
449
+ <div style="margin-bottom: 20px;">
450
+ <button class="btn btn-secondary" id="reset-btn">Reset Environment</button>
451
+ <button class="btn btn-secondary" id="state-btn">Get State</button>
452
+ </div>
453
+
454
+ <!-- Stats Display -->
455
+ <div class="stats-display">
456
+ <h3>Environment Stats</h3>
457
+ <div class="stats-grid">
458
+ <div class="stat-item">
459
+ <span class="stat-label">Step Count</span>
460
+ <span class="stat-value" id="step-count">0</span>
461
+ </div>
462
+ <div class="stat-item">
463
+ <span class="stat-label">Water Remaining</span>
464
+ <span class="stat-value" id="water-remaining">0</span>
465
+ </div>
466
+ <div class="stat-item">
467
+ <span class="stat-label">Breaks Remaining</span>
468
+ <span class="stat-value" id="breaks-remaining">0</span>
469
+ </div>
470
+ <div class="stat-item">
471
+ <span class="stat-label">Burning Cells</span>
472
+ <span class="stat-value" id="burning-count">0</span>
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <!-- Current State Display -->
478
+ <div class="state-display">
479
+ <h3>Current State</h3>
480
+ <div id="current-state">
481
+ <div class="state-item">
482
+ <span class="state-label">Status:</span>
483
+ <span class="state-value" id="env-status">Not initialized</span>
484
+ </div>
485
+ <div class="state-item">
486
+ <span class="state-label">Episode ID:</span>
487
+ <span class="state-value" id="episode-id">-</span>
488
+ </div>
489
+ <div class="state-item">
490
+ <span class="state-label">Wind Direction:</span>
491
+ <span class="state-value" id="wind-dir">-</span>
492
+ </div>
493
+ <div class="state-item">
494
+ <span class="state-label">Humidity:</span>
495
+ <span class="state-value" id="humidity">-</span>
496
+ </div>
497
+ </div>
498
+ </div>
499
+ </div>
500
+ </div>
501
+
502
+ <!-- Right Pane: Visual Grid and Logs -->
503
+ <div class="right-pane">
504
+ <div class="pane-header">
505
+ Fire Grid Visualization
506
+ </div>
507
+ <div class="pane-content">
508
+ <!-- Legend -->
509
+ <div class="legend">
510
+ <h3>Legend</h3>
511
+ <div class="legend-items">
512
+ <div class="legend-item">
513
+ <div class="legend-color" style="background-color: #2f2f2f;"></div>
514
+ <span>Ash (Burned)</span>
515
+ </div>
516
+ <div class="legend-item">
517
+ <div class="legend-color" style="background-color: #228b22;"></div>
518
+ <span>Fuel (Safe)</span>
519
+ </div>
520
+ <div class="legend-item">
521
+ <div class="legend-color" style="background-color: #ff4500;"></div>
522
+ <span>Burning (Fire)</span>
523
+ </div>
524
+ <div class="legend-item">
525
+ <div class="legend-color" style="background-color: #8b4513;"></div>
526
+ <span>Firebreak</span>
527
+ </div>
528
+ <div class="legend-item">
529
+ <div class="legend-color" style="background-color: #4169e1;"></div>
530
+ <span>Watered (Damp)</span>
531
+ </div>
532
+ </div>
533
+ </div>
534
+
535
+ <!-- Grid Visualization -->
536
+ <div class="grid-container">
537
+ <h3>Fire Grid</h3>
538
+ <div id="grid-status" style="margin-bottom: 10px; font-size: 12px; color: #666;">
539
+ Waiting for grid data... (Click "Reset Environment" to initialize)
540
+ </div>
541
+ <div class="grid-display">
542
+ <div id="fire-grid" class="grid">
543
+ <!-- Grid will be rendered here -->
544
+ </div>
545
+ </div>
546
+ <p style="margin-top: 10px; font-size: 12px; color: #666;">
547
+ Click on a cell to set coordinates for water/break actions
548
+ </p>
549
+ </div>
550
+
551
+ <!-- Action Logs -->
552
+ <div class="logs-container">
553
+ <h3>Action History</h3>
554
+ <div id="action-logs">
555
+ No actions taken yet
556
+ </div>
557
+ </div>
558
+ </div>
559
+ </div>
560
+ </div>
561
+
562
+ <script>
563
+ class WildfireWebInterface {{
564
+ constructor() {{
565
+ this.ws = null;
566
+ this.isConnected = false;
567
+ this.currentGrid = null;
568
+ this.gridWidth = 0;
569
+ this.gridHeight = 0;
570
+ this.init();
571
+ }}
572
+
573
+ init() {{
574
+ this.connectWebSocket();
575
+ this.setupEventListeners();
576
+ }}
577
+
578
+ connectWebSocket() {{
579
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
580
+ const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;
581
+
582
+ this.ws = new WebSocket(wsUrl);
583
+
584
+ this.ws.onopen = () => {{
585
+ this.isConnected = true;
586
+ this.updateConnectionStatus(true);
587
+ console.log('WebSocket connected');
588
+ // Trigger initial state fetch
589
+ this.fetchInitialState();
590
+ }};
591
+
592
+ this.ws.onmessage = (event) => {{
593
+ const data = JSON.parse(event.data);
594
+ if (data.type === 'state_update') {{
595
+ this.updateUI(data.episode_state);
596
+ }}
597
+ }};
598
+
599
+ this.ws.onclose = () => {{
600
+ this.isConnected = false;
601
+ this.updateConnectionStatus(false);
602
+ console.log('WebSocket disconnected');
603
+ setTimeout(() => this.connectWebSocket(), 3000);
604
+ }};
605
+
606
+ this.ws.onerror = (error) => {{
607
+ console.error('WebSocket error:', error);
608
+ }};
609
+ }}
610
+
611
+ async fetchInitialState() {{
612
+ // Fetch current state on connection to display grid
613
+ try {{
614
+ // Try to get current observation from state
615
+ const stateResponse = await fetch('/web/state');
616
+ const state = await stateResponse.json();
617
+
618
+ // If we have grid data in state, render it
619
+ if (state.grid && Array.isArray(state.grid) && state.width && state.height) {{
620
+ console.log('Rendering grid from state');
621
+ this.renderGrid(state.grid, state.width, state.height);
622
+ return;
623
+ }}
624
+
625
+ // If no grid in state, try to get it from the current episode state
626
+ // The WebSocket will send the current observation shortly
627
+ console.log('No grid in state, waiting for WebSocket update...');
628
+ }} catch (error) {{
629
+ console.error('Error fetching initial state:', error);
630
+ }}
631
+ }}
632
+
633
+ setupEventListeners() {{
634
+ // Instructions toggle
635
+ const instructionsToggle = document.getElementById('instructions-toggle');
636
+ const instructionsContent = document.getElementById('instructions-content');
637
+ if (instructionsToggle && instructionsContent) {{
638
+ instructionsToggle.addEventListener('click', () => {{
639
+ instructionsContent.classList.toggle('expanded');
640
+ instructionsToggle.textContent = instructionsContent.classList.contains('expanded')
641
+ ? 'Hide Instructions' : 'Show Instructions';
642
+ }});
643
+ }}
644
+
645
+ // Render README markdown into instructions (client-side, with proper markdown support)
646
+ const readmeMarkdown = window.__WILDFIRE_README__;
647
+ const instructionsTarget = document.getElementById('instructions-markdown');
648
+ if (instructionsTarget && readmeMarkdown) {{
649
+ try {{
650
+ if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {{
651
+ const html = DOMPurify.sanitize(marked.parse(readmeMarkdown));
652
+ instructionsTarget.innerHTML = html;
653
+ }}
654
+ }} catch (e) {{
655
+ console.error('Failed to render README markdown:', e);
656
+ }}
657
+ }}
658
+
659
+ // Action type change - show/hide coordinates
660
+ document.getElementById('action').addEventListener('change', (e) => {{
661
+ const coordsGroup = document.getElementById('coordinates-group');
662
+ if (e.target.value === 'water' || e.target.value === 'break') {{
663
+ coordsGroup.style.display = 'block';
664
+ document.getElementById('x').required = true;
665
+ document.getElementById('y').required = true;
666
+ }} else {{
667
+ coordsGroup.style.display = 'none';
668
+ document.getElementById('x').required = false;
669
+ document.getElementById('y').required = false;
670
+ }}
671
+ }});
672
+
673
+ // Form submission
674
+ document.getElementById('action-form').addEventListener('submit', (e) => {{
675
+ e.preventDefault();
676
+ this.submitAction();
677
+ }});
678
+
679
+ // Reset button
680
+ document.getElementById('reset-btn').addEventListener('click', () => {{
681
+ this.resetEnvironment();
682
+ }});
683
+
684
+ // State button
685
+ document.getElementById('state-btn').addEventListener('click', () => {{
686
+ this.getState();
687
+ }});
688
+ }}
689
+
690
+ async submitAction() {{
691
+ const formData = new FormData(document.getElementById('action-form'));
692
+ const action = {{}};
693
+
694
+ for (const [key, value] of formData.entries()) {{
695
+ if (value !== '') {{
696
+ if (key === 'x' || key === 'y') {{
697
+ action[key] = parseInt(value);
698
+ }} else {{
699
+ action[key] = value;
700
+ }}
701
+ }}
702
+ }}
703
+
704
+ // Remove x/y if action is 'wait'
705
+ if (action.action === 'wait') {{
706
+ delete action.x;
707
+ delete action.y;
708
+ }}
709
+
710
+ try {{
711
+ const response = await fetch('/web/step', {{
712
+ method: 'POST',
713
+ headers: {{ 'Content-Type': 'application/json' }},
714
+ body: JSON.stringify({{ action }})
715
+ }});
716
+
717
+ if (!response.ok) {{
718
+ throw new Error(`HTTP error! status: ${{response.status}}`);
719
+ }}
720
+
721
+ const result = await response.json();
722
+ console.log('Step result:', result);
723
+ }} catch (error) {{
724
+ console.error('Error submitting action:', error);
725
+ alert('Error submitting action: ' + error.message);
726
+ }}
727
+ }}
728
+
729
+ async resetEnvironment() {{
730
+ try {{
731
+ const response = await fetch('/web/reset', {{
732
+ method: 'POST',
733
+ headers: {{ 'Content-Type': 'application/json' }}
734
+ }});
735
+
736
+ if (!response.ok) {{
737
+ throw new Error(`HTTP error! status: ${{response.status}}`);
738
+ }}
739
+
740
+ const result = await response.json();
741
+ console.log('Reset result:', result);
742
+ console.log('Reset observation:', result.observation);
743
+
744
+ // Render grid immediately after reset
745
+ if (result.observation && result.observation.grid) {{
746
+ const obs = result.observation;
747
+ console.log('Grid data:', obs.grid);
748
+ console.log('Grid dimensions:', obs.width, 'x', obs.height);
749
+ if (obs.grid && Array.isArray(obs.grid) && obs.width && obs.height) {{
750
+ console.log('Rendering grid from reset...');
751
+ this.renderGrid(obs.grid, obs.width, obs.height);
752
+ }} else {{
753
+ console.warn('Grid data invalid:', {{
754
+ gridIsArray: Array.isArray(obs.grid),
755
+ width: obs.width,
756
+ height: obs.height
757
+ }});
758
+ }}
759
+ }} else {{
760
+ console.warn('No grid data in reset result:', result);
761
+ }}
762
+ }} catch (error) {{
763
+ console.error('Error resetting environment:', error);
764
+ alert('Error resetting environment: ' + error.message);
765
+ }}
766
+ }}
767
+
768
+ async getState() {{
769
+ try {{
770
+ const response = await fetch('/web/state');
771
+ const state = await response.json();
772
+ console.log('Current state:', state);
773
+ alert('Current state: ' + JSON.stringify(state, null, 2));
774
+ }} catch (error) {{
775
+ console.error('Error getting state:', error);
776
+ alert('Error getting state: ' + error.message);
777
+ }}
778
+ }}
779
+
780
+ updateConnectionStatus(connected) {{
781
+ const indicator = document.getElementById('connection-status');
782
+ if (connected) {{
783
+ indicator.className = 'status-indicator status-connected';
784
+ }} else {{
785
+ indicator.className = 'status-indicator status-disconnected';
786
+ }}
787
+ }}
788
+
789
+ updateUI(episodeState) {{
790
+ // Update state display
791
+ document.getElementById('env-status').textContent =
792
+ episodeState.is_reset ? 'Reset' : 'Running';
793
+ document.getElementById('episode-id').textContent =
794
+ episodeState.episode_id || '-';
795
+ document.getElementById('step-count').textContent =
796
+ episodeState.step_count.toString();
797
+
798
+ // Update observation if available
799
+ if (episodeState.current_observation) {{
800
+ const obs = episodeState.current_observation;
801
+
802
+ // Update stats
803
+ document.getElementById('water-remaining').textContent =
804
+ obs.remaining_water !== undefined ? obs.remaining_water : '-';
805
+ document.getElementById('breaks-remaining').textContent =
806
+ obs.remaining_breaks !== undefined ? obs.remaining_breaks : '-';
807
+ document.getElementById('burning-count').textContent =
808
+ obs.burning_count !== undefined ? obs.burning_count : '-';
809
+ document.getElementById('wind-dir').textContent =
810
+ obs.wind_dir || '-';
811
+ document.getElementById('humidity').textContent =
812
+ obs.humidity !== undefined ? obs.humidity.toFixed(2) : '-';
813
+
814
+ // Update grid visualization - handle both array and list formats
815
+ let gridData = obs.grid;
816
+ let gridWidth = obs.width;
817
+ let gridHeight = obs.height;
818
+
819
+ console.log('Updating grid from observation:', {{
820
+ hasGrid: !!gridData,
821
+ gridType: typeof gridData,
822
+ isArray: Array.isArray(gridData),
823
+ width: gridWidth,
824
+ height: gridHeight
825
+ }});
826
+
827
+ // Convert grid to array if it's not already
828
+ if (gridData && !Array.isArray(gridData)) {{
829
+ if (typeof gridData === 'string') {{
830
+ try {{
831
+ gridData = JSON.parse(gridData);
832
+ console.log('Parsed grid from string');
833
+ }} catch (e) {{
834
+ console.error('Error parsing grid data:', e);
835
+ gridData = null;
836
+ }}
837
+ }}
838
+ }}
839
+
840
+ // Ensure we have valid grid data
841
+ if (gridData && Array.isArray(gridData) && gridWidth && gridHeight) {{
842
+ console.log('Rendering grid from WebSocket update:', gridWidth, 'x', gridHeight, 'cells:', gridData.length);
843
+ this.renderGrid(gridData, gridWidth, gridHeight);
844
+ }} else {{
845
+ console.warn('Invalid grid data in WebSocket update:', {{
846
+ grid: gridData,
847
+ gridLength: gridData ? (Array.isArray(gridData) ? gridData.length : 'not array') : 'null',
848
+ width: gridWidth,
849
+ height: gridHeight
850
+ }});
851
+ }}
852
+ }}
853
+
854
+ // Update action logs
855
+ const logsDiv = document.getElementById('action-logs');
856
+ if (episodeState.action_logs.length === 0) {{
857
+ logsDiv.innerHTML = 'No actions taken yet';
858
+ }} else {{
859
+ logsDiv.innerHTML = episodeState.action_logs.map(log => `
860
+ <div class="log-entry">
861
+ <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div>
862
+ <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div>
863
+ <div>
864
+ <span class="log-reward">Reward: ${{log.reward !== null ? log.reward.toFixed(2) : 'None'}}</span>
865
+ ${{log.done ? '<span class="log-done">DONE</span>' : ''}}
866
+ </div>
867
+ </div>
868
+ `).join('');
869
+ }}
870
+ }}
871
+
872
+ renderGrid(grid, width, height) {{
873
+ this.gridWidth = width;
874
+ this.gridHeight = height;
875
+ this.currentGrid = grid;
876
+
877
+ const gridContainer = document.getElementById('fire-grid');
878
+ const gridStatus = document.getElementById('grid-status');
879
+
880
+ if (!gridContainer) {{
881
+ console.error('Grid container not found!');
882
+ return;
883
+ }}
884
+
885
+ // Validate grid dimensions
886
+ if (!width || !height || !grid || !Array.isArray(grid)) {{
887
+ console.error('Invalid grid parameters:', {{ width, height, grid }});
888
+ if (gridStatus) {{
889
+ gridStatus.innerHTML = '<span style="color: red;">Error: Invalid grid data</span>';
890
+ }}
891
+ gridContainer.innerHTML = '<p style="color: red;">Error: Invalid grid data</p>';
892
+ return;
893
+ }}
894
+
895
+ // Calculate grid size once
896
+ const gridSize = grid.length;
897
+ const expectedSize = width * height;
898
+
899
+ // Update status
900
+ if (gridStatus) {{
901
+ gridStatus.innerHTML = `Grid: ${{width}}×${{height}} (${{gridSize}} cells)`;
902
+ }}
903
+
904
+ // Check if grid size matches expected dimensions
905
+ if (gridSize !== expectedSize) {{
906
+ console.warn(`Grid size mismatch: expected ${{expectedSize}}, got ${{gridSize}}`);
907
+ }}
908
+
909
+ gridContainer.style.gridTemplateColumns = `repeat(${{width}}, 20px)`;
910
+ gridContainer.innerHTML = '';
911
+
912
+ // Grid encoding: 0=ash, 1=fuel, 2=burning, 3=firebreak, 4=watered
913
+ const cellClasses = ['ash', 'fuel', 'burning', 'firebreak', 'watered'];
914
+ const cellLabels = ['Ash', 'Fuel', 'Burning', 'Firebreak', 'Watered'];
915
+
916
+ console.log(`Rendering grid: ${{width}}x${{height}}, ${{gridSize}} cells`);
917
+
918
+ let renderedCells = 0;
919
+ for (let y = 0; y < height; y++) {{
920
+ for (let x = 0; x < width; x++) {{
921
+ const index = y * width + x;
922
+ const cellValue = (grid[index] !== undefined && grid[index] !== null) ? grid[index] : 0;
923
+ const cellClass = cellClasses[cellValue] || 'ash';
924
+ const cellLabel = cellLabels[cellValue] || 'Unknown';
925
+
926
+ const cell = document.createElement('div');
927
+ cell.className = `cell ${{cellClass}}`;
928
+ cell.title = `(${{x}}, ${{y}}): ${{cellLabel}} (value: ${{cellValue}})`;
929
+ cell.dataset.x = x;
930
+ cell.dataset.y = y;
931
+ cell.dataset.value = cellValue;
932
+
933
+ // Click to set coordinates
934
+ cell.addEventListener('click', () => {{
935
+ const xInput = document.getElementById('x');
936
+ const yInput = document.getElementById('y');
937
+ if (xInput) xInput.value = x;
938
+ if (yInput) yInput.value = y;
939
+ }});
940
+
941
+ gridContainer.appendChild(cell);
942
+ renderedCells++;
943
+ }}
944
+ }}
945
+
946
+ console.log(`Grid rendered: ${{width}}x${{height}} = ${{renderedCells}} cells`);
947
+
948
+ // Verify grid is visible
949
+ if (gridStatus) {{
950
+ gridStatus.innerHTML = `Grid: ${{width}}×${{height}} (${{renderedCells}} cells rendered) ✅`;
951
+ gridStatus.style.color = '#28a745';
952
+ }}
953
+ }}
954
+ }}
955
+
956
+ // Initialize the web interface when the page loads
957
+ document.addEventListener('DOMContentLoaded', () => {{
958
+ new WildfireWebInterface();
959
+ }});
960
+ </script>
961
+ </body>
962
+ </html>
963
+ """.replace('{_generate_instructions_section(instructions_html, metadata)}',
964
+ _generate_instructions_section(instructions_html, metadata))
965
+
966
+
967
+ def _generate_instructions_section(instructions_html: str, metadata: Optional[EnvironmentMetadata]) -> str:
968
+ """Generate the instructions section."""
969
+ if not instructions_html or not metadata:
970
+ return ''
971
+
972
+ return f'''
973
+ <!-- Instructions Section -->
974
+ <div class="instructions-section">
975
+ <div class="instructions-header">
976
+ <h3 class="instructions-title">{metadata.name if metadata else "Wildfire Environment"}</h3>
977
+ <button class="instructions-toggle" id="instructions-toggle">Show Instructions</button>
978
+ </div>
979
+ <div class="instructions-content" id="instructions-content">
980
+ <div class="instructions-readme">
981
+ <!-- Client-side rendered markdown target -->
982
+ <div id="instructions-markdown"></div>
983
+ <!-- Fallback (very simple conversion) -->
984
+ <noscript>
985
+ {instructions_html}
986
+ </noscript>
987
+ </div>
988
+ </div>
989
+ </div>
990
+ '''
991
+
992
+
993
+ def _markdown_to_html_simple(markdown: str) -> str:
994
+ """Convert basic markdown to HTML."""
995
+ import html
996
+ import re
997
+
998
+ # Escape HTML first
999
+ html_content = html.escape(markdown)
1000
+
1001
+ # Convert headers
1002
+ html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE)
1003
+ html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE)
1004
+ html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE)
1005
+
1006
+ # Convert code blocks
1007
+ html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL)
1008
+ html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content)
1009
+
1010
+ # Convert bold and italic
1011
+ html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content)
1012
+ html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content)
1013
+
1014
+ # Convert lists
1015
+ html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE)
1016
+ html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL)
1017
+
1018
+ # Convert line breaks
1019
+ html_content = html_content.replace('\n', '<br>')
1020
+
1021
+ return html_content
1022
+
uv.lock ADDED
The diff for this file is too large to render. See raw diff