00 Competition Quick Reference

Run Time
8 min
Hard cutoff by officials
Measurements
12
4 water + 8 soil
Closing Night
Fri 31 Jul
5 – 9 pm
Total Points
100
Six categories

Scoring Table (table totals 100; authoritative)

CategoryDescriptionPointsStrategy
Cat 1Autonomous operation & measurements30Complete all 12 samples autonomously
Cat 2Data output (logs, displays)10WiFi JSON + visualizer.html demo
Cat 3Build quality5Clean wiring, secure mounts
Cat 4Innovation10Hybrid RC + dead-reckoning 2.0 + visualizer
Cat 5Aesthetics5NeoPixel LEDs + tidy chassis
Cat 6Closing Night Case Competition40Data story + policy recommendation
TOTAL100Cat 6 is single largest category
Cat 6 = 40 pts (highest single category). A robot that works 80% + a great case presentation beats a perfect robot with a weak presentation. Budget 1 hour for Day 2 prep.

8-Minute Time Budget

ActivityTime
INIT + auto-calibration (place at center)5 s
Per-sector: 6 strips × 750mm at 100mm/s~75 s
Per-sector: 4 turns + 2 measurements + deployment~40 s
4 sectors total~460 s
Transit to center between sectors × 3~30 s
Contingency (stalls, RECOVERY)~15 s
TOTAL~510 s ≈ 8.5 min (tight; test at Day 1)
Fits within 8 min at 100mm/s. 6 strips per sector with 3×TCS34725 (100mm effective width). If over, cut to 5 strips and accept ~5% miss risk.

01 Mission

Navigate an octagonal 4-sector arena. In each sector locate 1 water pond and 2 soil patches at random positions. Water ponds are large: robot must sample from edge and reroute around them. Track position via encoder + IMU dead-reckoning. Maintain exclusion zones to avoid re-sampling. Operator can override at any time via mobile touch UI.

Sectors
4
Quadrant layout, ~90° each
Zones / Sector
3
1 water + 2 soil
Measurements
12
4 turbidity + 8 moisture
Speed
100mm/s
Fits 8-min limit

02 Arena Definition

ParameterValue
ShapeRegular octagon, ~1.5m diameter
WallsDark-stained timber, raised. Physical barrier. VL53L0X detects.
Layout4 equal quadrants divided by horizontal + vertical centerline
Sector boundariesNo physical dividers. Terrain color change only (TCS34725).
Zone positionsRandom within each quadrant per run. Must sweep, not memorise.
Water zonesRecessed diamond-shaped slots (~45°), blue water, slightly reflective
Soil zonesFlat reddish-brown circular discs (~8 total, ~2 per sector)

Terrain Types

QuadrantTerrainColorTCS34725 Dominant
Top-leftSandpaperLight beige, smooth matteHigh R+G, low B
Top-rightWet soilDark brown, reflectiveLow all, slight R
Bottom-leftGrassMuted green, uniformG dominant
Bottom-rightSandYellow-tan, ripple textureHigh R+G
Outer octagon wall (~1.5m diameter, dark timber) ╱─────────────────────────────────────────╲ ╱ ╲ │ Top-left: Top-right: │ │ SANDPAPER WET SOIL │ │ (light beige) (dark brown) │ │─────────────────────────┼───────────────────│ │ Bottom-left: Bottom-right: │ │ GRASS SAND │ │ (muted green) (yellow-tan) │ ╲ ╱ ╲─────────────────────────────────────────╱ ◆ = water zone (diamond slot, blue) ● = soil zone (reddish disc) Zone positions are RANDOM each run — robot must sweep, not navigate to fixed coords.
Zone positions are random per run. No memorised coordinates. Full sector sweep required every time.

03 Hardware Rev 5

Rev 5 changes vs Rev 4: ESP32-S3 → ESP32 (regular, from kit). DRV8833 → L298N (from kit). Added MPU6050 IMU + Hall encoders. Single LM2596 (L298N takes 12V direct). Kill switch added.

Controller — ESP32-WROOM-32 (from kit)

Dual-core Xtensa LX6, 240MHz, 4MB flash, WiFi b/g/n, 30-pin. Runs navigation, dead-reckoning fusion, color classification, measurement, HTTP + WebSocket server concurrently.

Sensors

SensorQtyBusRole
TCS347253Wire0 via TCA9548AL/C/R front array. Terrain ID, zone detection, boundary.
TCA9548A1Wire0 (0x70)I2C mux. All 3 TCS34725 share address 0x29.
VL53L0X1Wire1 (separate bus)Forward-facing. Outer wall and large obstacle detection.
MPU60501Wire0 (0x68)IMU DMP yaw fusion. 60% IMU / 40% encoder heading blend.
Hall encoder2GPIO ISRLeft + right shaft encoders. 20 ppr, 65mm wheel → 10.2mm/tick
SEN01891ADC GPIO 1Turbidity sensor (NTU), seesaw arm water side
Cap. Moisture v2.01ADC GPIO 2Soil moisture (%), seesaw arm soil side
VL53L0X address conflict resolved: Default 0x29 collides with TCS34725. Fix: VL53L0X on Wire1 (GPIO 21/22), TCS34725 on Wire0 (GPIO 8/9) via TCA9548A mux.

Actuation

ComponentQtyRole
DC geared motor 12V2Differential drive
L298N motor driver (from kit)112V direct input. IN1–IN4 direction + ENA/ENB PWM speed
Servo MG996R (or SG90)1Seesaw sensor arm, powered from 5V rail
Kill switch (large latching)1Top of chassis. Cuts battery line for technical inspection.
NeoPixel WS2812B1Status LED, state feedback

Power Architecture — Single Buck

8× AA batteries (12V nominal, ~10Ah, ~2h runtime estimate) │ ├──── KILL SWITCH (large latching, top of chassis) │ ├──── L298N MOTOR DRIVER (12V direct — no buck needed) │ ├── Motor Left │ └── Motor Right │ ├──── LM2596 Buck → 5.0V │ ├── Servo (4.8–7.2V ✓) │ ├── SEN0189 turbidity supply │ └── L298N 5V logic pin │ └──── AMS1117-3.3 (from 5V rail) ├── ESP32-WROOM-32 ├── TCS34725 ×3 ├── TCA9548A ├── VL53L0X (on Wire1) ├── MPU6050 └── Capacitive Moisture v2.0
Only 1 LM2596 needed. L298N accepts 12V direct. Rev 4 needed 2 bucks (DRV8833 required 9V). Saves board space and cost.

Physical Spec

ParameterValue
Chassis L × W × H200 × 160 × 250mm (arm vertical)
Weight estimate~850g (chassis + batteries + sensors)
Wheel diameter65mm
Wheelbase120mm center-to-center

04 Why 3 TCS34725 Sensors

Capability1 sensor3 sensors
Detect zone exists
Center arm accurately
Detect water from side approach
Distinguish zone vs sector boundary
100mm effective strip width
Reliable wall detection at angle

Arm centering logic

LCRAction
TERRAINZONETERRAINCentered → deploy arm
ZONEZONETERRAINDrifted left → nudge right
TERRAINZONEZONEDrifted right → nudge left
ZONETERRAINTERRAINZone to left → steer toward

Strip coverage

Sensor spread: 3 sensors × 20mm spacing = 60mm Effective strip width: 2 × zone_radius + spread = 100mm per pass Sector depth: ~750mm → 750 / 100 = 7.5 → use 6 strips (overlap safety) 2.5× fewer strips than 1 sensor (40mm strip) → faster scan

05 Chassis Layout

FRONT ┌──────────────────────────────────────┐ │ [VL53L0X] ← wall detection │ ← Wire1 (GPIO 21/22) │ [TCS-L] [TCS-C] [TCS-R] │ ← Wire0, 8mm above ground │ │ │ ╔═════ SERVO ARM (seesaw) ════════╗ │ │ ║ [SEN0189] [Cap. Moisture] ║ │ ← pivots down from front │ ╚══════════════════════════════════╝ │ │ │ │ ◉ Motor-L Motor-R ◉ │ │ [Hall Enc-L] [Hall Enc-R] │ ← 20 ppr, shaft-mounted │ │ │ [ESP32] [L298N] [MPU6050] │ │ [8×AA pack 12V] [LM2596→5V] │ │ [KILL SW] │ ← top, accessible └──────────────────────────────────────┘ REAR Wheel axle: ≥80mm behind TCS34725 (water safety clearance) TCS34725 spacing: 20mm apart, pointing straight down

06 Pin Assignment Rev 5

GPIOFunctionNotes
8 / 9Wire0 SDA / SCLTCS34725 ×3 via TCA9548A (0x70) + MPU6050 (0x68)
21 / 22Wire1 SDA / SCLVL53L0X only. Separate bus avoids 0x29 address collision.
1SEN0189 ADCVoltage divider ×1.5 correction applied in firmware
2Soil moisture ADC3.3V safe, direct
10L298N IN1 (Motor-L)Direction
11L298N IN2 (Motor-L)Direction
12L298N IN3 (Motor-R)Direction
13L298N IN4 (Motor-R)Direction
14L298N ENA (Motor-L PWM)analogWrite speed control
15L298N ENB (Motor-R PWM)analogWrite speed control
16Servo PWM50Hz, 1–2ms pulse width
17Hall encoder LEFTISR rising edge → enc_left++
18Hall encoder RIGHTISR rising edge → enc_right++
23NeoPixel WS2812B dataSingle LED status indicator

07 Dead-Reckoning 2.0 — Encoder + IMU Fusion

Rev 5 upgrade: Time-based DR (±60mm) replaced with Hall encoder odometry fused with MPU6050 DMP yaw. Target accuracy ±20mm, tight enough for 80mm exclusion zones.

Encoder constants

cpp#define WHEEL_DIAM_MM   65.0f
#define PULSES_PER_REV  20
#define MM_PER_TICK     (3.14159f * WHEEL_DIAM_MM / PULSES_PER_REV)  // ≈ 10.2mm
#define WHEEL_BASE_MM   120.0f
#define EXCL_RADIUS     80.0f   // mm — tighter than Rev 4 (120mm) due to ±20mm accuracy

volatile int32_t enc_left = 0, enc_right = 0;
void IRAM_ATTR isr_left()  { enc_left++;  }
void IRAM_ATTR isr_right() { enc_right++; }

Fused position update (call every 20ms)

cppvoid update_pos_v2() {
    noInterrupts();
    int32_t dl_ticks = enc_left;
    int32_t dr_ticks = enc_right;
    enc_left = enc_right = 0;
    interrupts();

    float dl = dl_ticks * MM_PER_TICK;
    float dr = dr_ticks * MM_PER_TICK;
    float dc = (dl + dr) * 0.5f;
    float dtheta_enc = (dr - dl) / WHEEL_BASE_MM;
    float dtheta_imu = get_imu_delta_yaw();  // MPU6050 DMP
    float dtheta = 0.6f * dtheta_imu + 0.4f * dtheta_enc;

    pos.heading += dtheta;
    pos.x += cosf(pos.heading) * dc;
    pos.y += sinf(pos.heading) * dc;
}  // origin (0,0) = arena center

Exclusion zones

cppbool already_sampled(float x, float y) {
    for (int i = 0; i < sample_count; i++) {
        float d = hypotf(x - past_samples[i].x, y - past_samples[i].y);
        if (d < EXCL_RADIUS) return true;
    }
    return false;
}

Accuracy comparison

MethodAccuracyCost
Time-based DR (Rev 4)±60mm$0
Encoder only±30mm~$6 AUD
Encoder + MPU6050 DMP REV 5±20mm~$13 AUD
UWB (BU01/DW1000)±10mmNot feasible (needs external anchors)

08 Water Pond — Sampling and Rerouting

Pond is large. Robot cannot cross. Detect → stop → sample from edge → reroute around → resume sweep.
cpp#define TURN_90_MS  1885  // π/2 × WHEEL_BASE_MM / SPEED_MM_S × 1000
                          // = 1.5708 × 120 / 100 × 1000 = 1885ms

void on_water_detected() {
    stop_motors();
    if (already_sampled(pos.x, pos.y)) { state = POND_REROUTE; return; }
    arm_water_down(); delay(1500);
    float ntu = read_turbidity(); arm_neutral();
    char val[16]; snprintf(val, 16, "%.1f NTU", ntu);
    record_sample(current_sector, WATER, val);
    state = POND_REROUTE;
}

void reroute_around_pond() {
    reverse(100, 800);
    turn_right(100, TURN_90_MS);
    while (true) {
        bool wR = in_water(sensors[RIGHT]);
        bool wC = in_water(sensors[CENTER]);
        if (wC) { turn_right(60, 200); }
        else if (!wR) { forward(100, 800); break; }
        else          { forward(100, 100); }
        update_pos_v2();
    }
    turn_left(100, TURN_90_MS);
    state = SWEEP;
}
Current heading → ───────── [POND] ───────────── ↑ Water detected │ Reverse 800ms (100mm/s) │ Turn right 1885ms (90°) │ Follow pond edge (R=water) ──────→ │ Turn left 1885ms │ ────────────────────────────────────→ Resume heading, state = SWEEP

09 Navigation — Sector Coverage

Strip math (Rev 5)

Drive speed: 100 mm/s (was 40mm/s in Rev 4 — FATAL time issue) Strip width: 100 mm (3-sensor array) Strips/sector: 6 (600mm coverage with overlap in 750mm depth) Time/strip: 7.5s drive + 3.5s turns = 11s 4 sectors total: 4 × (6×11s + 2×5s sample) = 4 × 76s = 304s ≈ 5.1 min INIT + transits: ~60s Total estimate: ~364s ≈ 6.1 min ✓ margin: ~114s

Terrain color classification

cpp#define TERRAIN_THRESHOLD  0.25f

float color_distance(ColorReading a, ColorReading b) {
    float tA = a.r+a.g+a.b+1, tB = b.r+b.g+b.b+1;
    float rA=(float)a.r/tA, gA=(float)a.g/tA;
    float rB=(float)b.r/tB, gB=(float)b.g/tB;
    return sqrtf(powf(rA-rB,2)+powf(gA-gB,2));
}

bool in_current_terrain(ColorReading r) {
    return color_distance(r, terrain_baseline) < TERRAIN_THRESHOLD;
}
// in_water(): b/(r+g+b+1) > 0.45f
// in_soil():  r/(r+g+b+1) > 0.40f  (reddish disc)

Terrain-specific PWM

TerrainRiskENA/ENB PWM duty
SandpaperHigh friction → stall45%
GrassHigh drag, uneven70%
Wet soilSlipping50%
SandWheel sinking40%

Target 100mm/s on all terrains. Encoder feedback confirms actual speed; adjust PWM during Day 1 test.

10 State Machine

INIT │ Wire0 + Wire1 init · WiFi AP · WebSocket :81 · SPIFFS mount │ MPU6050 DMP · encoder ISRs attach · arm_neutral() · NeoPixel blue │ auto-calibrate 5s · pos=(0,0,0) · sample_count=0 · sector=0 ↓ SWEEP ← lawnmower in current sector │ update_pos_v2() every 20ms · check_stall() every 20ms │ TCS34725 poll every 50ms │ at_outer_wall() → reverse + next strip │ ANY sensor WATER → POND_SAMPLE (exclusion check first) │ CENTER sensor SOIL → APPROACH_SOIL (exclusion check first) │ All 6 strips done → terrain_complete() ? │ yes → TRANSIT │ no → STRIP_WIDTH /= 2, re-sweep │ check_stall() → RECOVERY ↓ POND_SAMPLE │ already_sampled? → POND_REROUTE │ arm_water_down · read_turbidity · arm_neutral │ record_sample(sector, WATER, "X.X NTU") │ → POND_REROUTE ↓ POND_REROUTE │ reverse 800ms · turn_right 1885ms · follow pond edge │ cleared → turn_left 1885ms → SWEEP ↓ APPROACH_SOIL │ forward ~100mm until arm pivot over disc │ → MEASURE_SOIL ↓ MEASURE_SOIL │ already_sampled? → SWEEP │ arm_soil_down · read_moisture · arm_neutral │ record_sample(sector, SOIL, "X.X%") │ → SWEEP ↓ RECOVERY ← check_stall() fires (encoders idle >3s during SWEEP) │ stop_motors · NeoPixel purple │ pos.x=0; pos.y=0; pos.heading=get_imu_heading() (re-sync) │ wait for encoder movement │ → SWEEP (restart strip 1) ↓ TRANSIT │ drive to center (0,0) · MPU6050 heading re-sync │ sector++ · sector == 4 → DONE ↓ DONE stop · NeoPixel green · write_results_json() · Serial.print all

Stall detection (RECOVERY trigger)

cppvoid check_stall() {
    static int stall_ms = 0;
    if (state == SWEEP && enc_left == 0 && enc_right == 0) {
        stall_ms += 20;
        if (stall_ms > 3000) { stall_ms = 0; state = RECOVERY; }
    } else { stall_ms = 0; }
}  // called every 20ms from main loop

11 Zone Coverage Guarantee

Coverage condition: STRIP_WIDTH ≤ 2×zone_radius + sensor_spread Water slot est. width 50mm: 100 ≤ 50 + 60 = 110mm ✓ Soil disc est. radius 30mm: 100 ≤ 60 + 60 = 120mm ✓ Soil disc radius 15mm worst: 100 ≤ 30 + 60 = 90mm ✗ → halve STRIP_WIDTH Re-sweep: if terrain_complete() false after all strips → STRIP_WIDTH = 50mm, repeat sector (adds ~40s per sector)

12 No-Duplicate Guarantee

cppbool terrain_complete(int sector) {
    int water=0, soil=0;
    for (int i=0; i<sample_count; i++) {
        if (in_sector(past_samples[i].x, past_samples[i].y, sector)) {
            if (past_samples[i].type==WATER) water++;
            if (past_samples[i].type==SOIL)  soil++;
        }
    }
    return (water >= 1 && soil >= 2);
}
// already_sampled(x,y) checked before every arm deployment — EXCL_RADIUS=80mm

13 Measurement Code

Standardised record_sample

cpptypedef struct {
    float x, y;
    int sector;
    enum { WATER, SOIL } type;
    uint32_t timestamp_ms;
    char value[16];  // "42.3 NTU" or "61.2%"
} Sample;

Sample past_samples[16];
int sample_count = 0;

void record_sample(int sector, int type, const char* value) {
    Sample& s = past_samples[sample_count];
    s.x = pos.x; s.y = pos.y;
    s.sector = sector; s.type = type;
    s.timestamp_ms = millis();
    strncpy(s.value, value, sizeof(s.value)-1);
    sample_count++;
}

Servo arm + read functions

cppvoid arm_neutral()    { arm.write(90);  delay(500); }
void arm_water_down() { arm.write(0);   delay(600); }  // CCW → water side
void arm_soil_down()  { arm.write(180); delay(600); }  // CW  → soil side

float read_turbidity() {
    float v = (analogRead(TURB_PIN)/4095.0f) * 3.3f * 1.5f;
    return fmaxf(0, -1120.4f*v*v + 5742.3f*v - 4352.9f);
}

float read_moisture() {
    int raw = analogRead(SOIL_PIN);
    return fmaxf(0, fminf(100, (float)(SOIL_DRY-raw)/(SOIL_DRY-SOIL_WET)*100.0f));
}

void sample_water(int sector) {
    arm_water_down(); delay(1500);
    float ntu = read_turbidity(); arm_neutral();
    char val[16]; snprintf(val, 16, "%.1f NTU", ntu);
    record_sample(sector, WATER, val);
}

void sample_soil(int sector) {
    arm_soil_down(); delay(1500);
    float pct = read_moisture(); arm_neutral();
    char val[16]; snprintf(val, 16, "%.1f%%", pct);
    record_sample(sector, SOIL, val);
}

14 Data Storage — SPIFFS JSON

After each sample, results written to /results.json in SPIFFS. Served at 192.168.4.1/results. Also dumped to Serial on DONE. Uses ArduinoJson v6.

JSON output schema

json{
  "run_id": "envirobot_001",
  "total_samples": 12,
  "duration_ms": 420000,
  "samples": [
    {
      "id": 1,
      "sector": 1,
      "type": "water",
      "value": "42.3 NTU",
      "x_mm": 310.5,
      "y_mm": -125.8,
      "timestamp_ms": 34200
    },
    {
      "id": 2,
      "sector": 1,
      "type": "soil",
      "value": "61.2%",
      "x_mm": 280.1,
      "y_mm": -200.3,
      "timestamp_ms": 67800
    }
  ]
}

Write function (ArduinoJson v6)

cppvoid write_results_json() {
    StaticJsonDocument<4096> doc;
    doc["run_id"] = "envirobot_001";
    doc["total_samples"] = sample_count;
    doc["duration_ms"] = millis();
    JsonArray arr = doc.createNestedArray("samples");
    for (int i=0; i<sample_count; i++) {
        JsonObject o = arr.createNestedObject();
        o["id"] = i+1;
        o["sector"] = past_samples[i].sector;
        o["type"] = (past_samples[i].type==WATER) ? "water" : "soil";
        o["value"] = past_samples[i].value;
        o["x_mm"] = past_samples[i].x;
        o["y_mm"] = past_samples[i].y;
        o["timestamp_ms"] = past_samples[i].timestamp_ms;
    }
    File f = SPIFFS.open("/results.json", "w");
    serializeJson(doc, f);
    f.close();
}

HTTP endpoints

MethodPathResponse
GET/Dashboard HTML
GET/resultsresults.json download
GET/statusCurrent state + pos + sample_count JSON
GET/rcMobile remote control UI

15 Auto-Calibration at Startup

Zero manual setup. Place robot at arena center, power on, wait 5 seconds for green LED.
NeoPixelState
🔵 Blue slow pulseINIT / calibrating. Do not move.
🟢 GreenReady / DONE
🟡 YellowZone detected, arm deploying
⚪ WhiteMeasuring
🔴 RedWall / rerouting / stall
🟣 PurpleRECOVERY state. Waiting to be replaced.
🔵 Blue fast blinkMANUAL RC override active

16 Communications — WiFi AP Mode

No internet required. ESP32 creates its own WiFi network. Phone/laptop connects directly. No router needed.
cppWiFi.softAP("EnviroBot", "enviro123");
// Connect phone to "EnviroBot" WiFi → open 192.168.4.1
// HTTP server: port 80  (pages + JSON)
// WebSocket:   port 81  (RC commands + status push)
ChannelProtocolPortUse
Web pages + data downloadHTTP80Dashboard, /rc, /results
RC joystick commandsWebSocket81~10ms latency joy packets
Status streamWebSocket81State + pos push every 500ms

17 Remote Control — Mobile Touch UI

Semi-autonomous hybrid. Robot runs autonomously by default. Operator can override at any moment. Robot still auto-deploys sensors even in MANUAL mode.

Mobile UI at 192.168.4.1/rc

┌─────────────────────────────────────────────┐ │ EnviroBot RC 🔋 12.1V ● 4/12 samples│ │─────────────────────────────────────────────│ │ │ │ ┌─────────────────────────────────────┐ │ │ │ 3D Arena mini-view (Three.js) │ │ │ │ robot dot + path + zone markers │ │ │ └─────────────────────────────────────┘ │ │ │ │ [AUTO ↔ MANUAL] Speed [──●────] │ │ │ │ ╭─────────────╮ │ │ │ │ │ │ │ nipple.js │ ← virtual │ │ │ Joystick │ joystick │ │ │ │ │ │ ╰─────────────╯ │ │ │ │ State: SWEEP Sector: 2/4 Pos: 310,−125 │ └─────────────────────────────────────────────┘

WebSocket message format

json// Phone → Robot (RC command):
{ "cmd": "joy", "x": 0.85, "y": 0.30 }
// x: left/right (−1 to +1), y: forward/back (−1 to +1)
{ "cmd": "auto" }      // resume autonomous from current position
{ "cmd": "stop" }      // immediate stop
{ "cmd": "arm_water" } // manual arm trigger

// Robot → Phone (status push every 500ms):
{ "state": "SWEEP", "sector": 2, "samples": 4, "x": 310.5, "y": -125.8 }

Joystick to motor PWM

cppvoid apply_joystick(float jx, float jy) {
    float left  = fmaxf(-1, fminf(1, jy + jx));  // tank-drive mix
    float right = fmaxf(-1, fminf(1, jy - jx));
    set_motors((int)(left*255), (int)(right*255));
}
Libraries needed: ESPAsyncWebServer + AsyncWebSocket (Arduino). nipple.js loaded from CDN in the /rc page. No app install on phone.

18 Data Visualization App — visualizer.html

Standalone single HTML file. Drag-drop results.json → renders 3D arena + charts. Open in any browser, no server needed. Scores Cat 2 (data output) + supports Cat 6 (case presentation).

Layout

┌──────────────────────────────────────────────────────────────┐ │ EnviroBot Data Visualizer [📂 Load JSON] │ │──────────────────────────────────────────────────────────────│ │ │ │ │ 3D ARENA VIEW │ TURBIDITY (NTU) — 4 water samples │ │ Three.js r165 │ S1 ██████░░ 42.3 NTU │ │ │ S2 ████████ 81.1 NTU ← above EPA │ │ ◆ water zones │ S3 ████░░░░ 21.0 NTU │ │ ● soil zones │ S4 ███████░ 63.4 NTU │ │ robot path line │ │ │ │ SOIL MOISTURE (%) — 8 samples │ │ [click zone] │ S1-A ████░░ 61% S1-B ██░░ 38% │ │ → popup: value, │ S2-A ████████ 82% S2-B ████░ 71% │ │ sector, time │ S3-A █░░░░░ 22% S3-B █░░░░ 19% │ │ │ S4-A ███████ 78% S4-B ████░ 52% │ │────────────────── │────────────────────────────────────────│ │ ──●───────────── │ TIMELINE: drag to replay robot path │ │ 0s 480s │ ◀ ● ▶ 02:15 Sample 6 of 12 │ └──────────────────────────────────────────────────────────────┘

Features

FeatureImplementation
3D arenaThree.js r165 from CDN. Octagon walls, 4 terrain quadrant planes with terrain colors
Zone markersWater = blue diamond mesh (rotated plane). Soil = reddish-brown disc. Placed from x_mm/y_mm
Robot pathTHREE.Line geometry connecting sample positions in timestamp order
Click-to-inspectTHREE.Raycaster on click → popup shows value, sector, terrain, timestamp
Turbidity bar chartChart.js 4 horizontal bars. Color-coded: green <4 NTU (safe), yellow 4-50, red >50
Moisture bar chartChart.js 4. 8 bars grouped by sector. Color: blue-scale (dry=light, wet=dark)
Timeline scrubberHTML range input. Drag → show robot position at that moment, highlight active sample
Drag-drop JSONDrop results.json anywhere on page → parse → render all. Or click 📂 to browse file
Dark themeMatches plan doc color system (--bg #0d1117, --blue #58a6ff)

Three.js bootstrap

js// Three.js r165 from CDN, Chart.js 4 from CDN
const scene  = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w/h, 1, 5000);
camera.position.set(0, 900, 900);
camera.lookAt(0, 0, 0);

const terrainColors = [0xD2B48C, 0x4E342E, 0x558B2F, 0xF9A825];
// sandpaper, wet soil, grass, sand

function loadResults(json) {
  json.samples.forEach(s => {
    const mesh = s.type === 'water' ? makeWaterDiamond() : makeSoilDisc();
    mesh.position.set(s.x_mm * 0.5, 2, s.y_mm * 0.5);
    mesh.userData = s;
    scene.add(mesh);
  });
  drawPath(json.samples);
  buildCharts(json.samples);
}
Self-contained file. Share visualizer.html: anyone can open it, drop their results.json, and get the full analysis. No install, no backend.

19 Scoring Strategy

Cat 6 — Case Competition
40 pts
Cat 1 — Autonomous operation
30 pts
Cat 2 — Data output
10 pts
Cat 4 — Innovation
10 pts
Cat 3 — Build quality
5 pts
Cat 5 — Aesthetics
5 pts

Score-per-effort map

CategoryPtsWhat locks it inRisk
Cat 6 (Case Comp)40Strong narrative + visualizer + policy recsLOW (fully in our control)
Cat 1 (Autonomous)30Complete all 12 samples in 8 minMED (test on Day 1)
Cat 2 (Data)10WiFi JSON + visualizer.html live demoLOW (software deliverable)
Cat 4 — Innovation10Pitch hybrid RC + DR 2.0 + viz app explicitlyLOW
Cat 3 — Build5Clean harness, hot-glue or zip-tie all wiresLOW
Cat 5 — Aesthetics5NeoPixel LEDs + printed chassis panelsLOW

Day 1 Pitch (3 min)

  1. [0:00–0:30] Problem: environmental monitoring in inaccessible terrain. Manual = slow, expensive, dangerous.
  2. [0:30–1:00] Solution: EnviroBot. Autonomous survey, dead-reckoning 2.0, 12 samples in 8 min.
  3. [1:00–1:45] Live demo: power on → calibrate → first sector sweep → sample logged on phone UI.
  4. [1:45–2:15] Innovation: hybrid RC, encoder+IMU fusion, mobile touch UI, single-file data viz.
  5. [2:15–2:45] Data story: open visualizer.html → 3D arena → explain what the readings mean.
  6. [2:45–3:00] Call to action: fleet deployment = early flood/contamination detection at 5% of current cost.

20 Day 2 — Closing Night Case Competition

Cat 6 = 40 pts. Worth more than Cat 2+3+4+5 combined. Invest real prep time here.

1-Hour Day 2 Prep Plan

Case Argument Template

SectionContent (fill with real data)Time
Hook"In 8 minutes, our robot found contamination in 2 of 4 water zones that manual inspection would have missed."30s
DataShow visualizer 3D arena. Highlight highest-NTU zone. "Sector X turbidity = Y NTU, above EPA threshold of 4 NTU."90s
Scale"10 robots cover a 100m flood zone in under an hour. Manual = 2 days, $5000. Robot fleet = 4 hours, $200."60s
Policy1) Deploy robot fleets post-flood events. 2) Integrate with council water alert systems. 3) Open-source firmware for NGO deployment.90s
Close"The data is here. The robot is here. The only question is who deploys it first."30s
Visualizer as closing prop. End with 3D arena on screen, all 12 sample points visible. Invite judges to click zones if time allows.

21 Bill of Materials

From Kit (no cost)

ItemNotes
ESP32-WROOM-32Main controller
L298N motor driverDual H-bridge, 12V direct
DC geared motors ×212V
Servo motor (MG996R or SG90)
8× AA battery holder12V nominal
LM2596 buck moduleSet to 5V output
AMS1117-3.3 LDO3.3V from 5V rail
Breadboard + jumper wires

Additional Purchases

ItemQtyEst. AUD
TCS34725 color sensor3$12
TCA9548A I2C mux1$4
VL53L0X distance sensor1$5
MPU6050 IMU module1$4
Hall encoder wheel kit (pair)1$8
SEN0189 turbidity sensor1$10
Capacitive soil moisture v2.01$3
Large latching kill switch1$4
NeoPixel WS2812B1$3
8× AA alkaline batteries2 packs$6
TOTAL ADDITIONAL~$59 AUD
~$59 AUD
within $50–150 AUD budget
TCS34725 + MPU6050: AliExpress/eBay (~1-week lead time). SEN0189, hall encoders: Core Electronics or Amazon AU (next-day). Jaycar for kill switch.

22 Pre-Competition Checklist

Technical Inspection

Day 1 Morning (Test Day)

Day 2 Morning