{
  "id": "4FDgfnxGjKzZtTzQ",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Migratory Pest Wind-Vector Tracker",
  "tags": [],
  "nodes": [
    {
      "id": "57aa1e0d-18df-47be-9372-51822c8a4e69",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 480,
        "height": 720,
        "content": "## Migratory Pest Wind-Vector Tracker\n### How it works\n\n1. Automatically triggers every day at 6 AM with preset farm configuration.\n2. Queries the GBIF API to find pest occurrences near the farm location.\n3. Retrieves weather and wind vector data to calculate wind alignment threat.\n4. Uses an AI agent with Google Gemini to draft a scout alert if conditions are met.\n5. Notifies field scouts via Slack with the generated alert.\n\n### Setup steps\n\n- - [ ] Configure farm coordinates, radius, and lookback days in the Config set node.\n- - [ ] Set up credentials for the Google Gemini chat model used by the AI agent.\n- - [ ] Configure Slack credentials and destination channel for the field scout alerts.\n\n### Customization\n\nAdjust farm coordinates and search radius in the Config node or modify the lookback days and wind thresholds to suit specific crop requirements."
      },
      "typeVersion": 1
    },
    {
      "id": "d6ff980f-7247-4b7b-9802-ec7cfb137634",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Trigger and configuration setup\n\nTriggers the workflow daily at 6 AM and initializes the farm location and search radius configuration."
      },
      "typeVersion": 1
    },
    {
      "id": "dd8731e2-1fea-497c-ac55-f935dea46ec2",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1008,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Search and filter pest occurrences\n\nSearches GBIF for recent pest occurrences and checks if any pests were reported nearby."
      },
      "typeVersion": 1
    },
    {
      "id": "d00e489b-105b-4093-8e0d-97c858ca97d3",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1456,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 304,
        "content": "## Fetch weather and calculate threat\n\nFetches wind data, calculates wind alignment vector threat, and checks if wind is blowing toward the farm."
      },
      "typeVersion": 1
    },
    {
      "id": "7c04bbc6-b3ee-4abf-b53a-b09780036f38",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2128,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 496,
        "content": "## Generate AI alert and notify Slack\n\nDrafts a scout alert using an AI agent powered by Gemini and sends the notification to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "11877398-8379-4c41-a915-e141e913a5b8",
      "name": "Every Morning at 6am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        608,
        160
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 6 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fc2ea7f0-d333-4776-af24-91ffdae22dd4",
      "name": "Set Search Parameters",
      "type": "n8n-nodes-base.set",
      "position": [
        832,
        160
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "10e70c86-160e-40e0-badf-b2a09b6529dc",
              "name": "farm_latitude",
              "type": "string",
              "value": "enter-your-lat-here-and-change-string-to-number"
            },
            {
              "id": "85d27b39-5817-4fda-b32a-e41331526b0e",
              "name": "farm_longitude",
              "type": "string",
              "value": "enter-your-long-here-and-change-string-to-number"
            },
            {
              "id": "294f2973-9e61-4c88-a28f-6519d4632eb9",
              "name": "radius_km",
              "type": "number",
              "value": 100
            },
            {
              "id": "9315e0d1-0243-4805-a799-b4a6557eacbf",
              "name": "lookback_days",
              "type": "number",
              "value": 2
            },
            {
              "id": "da24085b-8cba-42bc-a920-e7023c963624",
              "name": "wind_lookback_days",
              "type": "number",
              "value": 2
            },
            {
              "id": "e8c4e0b2-5ba5-4307-ac66-9ac398f77a57",
              "name": "alignment_tolerance_deg",
              "type": "number",
              "value": 45
            },
            {
              "id": "07a47498-0366-47c5-87e5-578f5cf7fc18",
              "name": "slack_channel",
              "type": "string",
              "value": "#general [edit this to your preferred slack channel name]"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "9855253c-4efe-458f-8d39-0203274e041e",
      "name": "Fetch GBIF Pest Occurrences",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1056,
        160
      ],
      "parameters": {
        "url": "https://api.gbif.org/v1/occurrence/search",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "geoDistance",
              "value": "={{ $json.farm_latitude }},{{ $json.farm_longitude }},{{ $json.radius_km }}km"
            },
            {
              "name": "establishmentMeans",
              "value": "INTRODUCED"
            },
            {
              "name": "establishmentMeans",
              "value": "INVASIVE"
            },
            {
              "name": "eventDate",
              "value": "={{ $now.minus({ days: $json.lookback_days }).toFormat('yyyy-MM-dd') }},{{ $now.toFormat('yyyy-MM-dd') }}"
            },
            {
              "name": "hasCoordinate",
              "value": "true"
            },
            {
              "name": "limit",
              "value": "50"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "e56644e3-9509-4db3-a10e-11a6c00f73e0",
      "name": "Check for Nearby Pests",
      "type": "n8n-nodes-base.if",
      "position": [
        1280,
        160
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "12dcef8c-a100-4a27-9554-2dfb094d1cc6",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.count }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "49acd782-7bba-4db6-bd44-e67c8237d820",
      "name": "Fetch Wind Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1504,
        176
      ],
      "parameters": {
        "url": "https://api.open-meteo.com/v1/forecast",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "latitude",
              "value": "={{ $('Set Search Parameters').first().json.farm_latitude }}"
            },
            {
              "name": "longitude",
              "value": "={{ $('Set Search Parameters').first().json.farm_longitude }}"
            },
            {
              "name": "hourly",
              "value": "wind_speed_10m,wind_direction_10m"
            },
            {
              "name": "past_days",
              "value": "={{ $('Set Search Parameters').first().json.wind_lookback_days }}"
            },
            {
              "name": "forecast_days",
              "value": "1"
            },
            {
              "name": "timezone",
              "value": "auto"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "60ef602b-6286-41da-b60a-35f4e66d1104",
      "name": "Calculate Wind Threat Alignment",
      "type": "n8n-nodes-base.code",
      "position": [
        1728,
        176
      ],
      "parameters": {
        "jsCode": "// Wind-Vector Threat Calculator\n// Compares each GBIF pest occurrence against the 48h wind history\n// and flags occurrences the wind is actively carrying toward the farm.\n\nconst cfg = $('Set Search Parameters').first().json;\nconst farmLat = parseFloat(cfg.farm_latitude);\nconst farmLon = parseFloat(cfg.farm_longitude);\nconst toleranceDeg = parseFloat(cfg.alignment_tolerance_deg);\n\nconst occurrences = $input.first().json.results || [];\n\n// --- wind history comes from the previous node (Open-Meteo) ---\nconst wind = $('Fetch Wind Data').first().json.hourly;\nconst speeds = wind.wind_speed_10m || [];\nconst dirs = wind.wind_direction_10m || [];\n\n// Circular mean of wind direction over the lookback window\nlet sinSum = 0, cosSum = 0;\nfor (const d of dirs) {\n  const rad = (d * Math.PI) / 180;\n  sinSum += Math.sin(rad);\n  cosSum += Math.cos(rad);\n}\nlet avgWindFrom = (Math.atan2(sinSum, cosSum) * 180) / Math.PI;\nif (avgWindFrom < 0) avgWindFrom += 360;\nconst avgWindSpeed = speeds.reduce((a, b) => a + b, 0) / (speeds.length || 1);\n\n// Direction the wind is actually blowing TOWARD (opposite of \"from\")\nconst windBlowingToward = (avgWindFrom + 180) % 360;\n\nfunction toRad(d) { return (d * Math.PI) / 180; }\nfunction toDeg(r) { return (r * 180) / Math.PI; }\n\n// Great-circle bearing from point A to point B (degrees, 0-360)\nfunction bearing(lat1, lon1, lat2, lon2) {\n  const y = Math.sin(toRad(lon2 - lon1)) * Math.cos(toRad(lat2));\n  const x =\n    Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) -\n    Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(toRad(lon2 - lon1));\n  let brng = toDeg(Math.atan2(y, x));\n  return (brng + 360) % 360;\n}\n\n// Haversine distance in km\nfunction distanceKm(lat1, lon1, lat2, lon2) {\n  const R = 6371;\n  const dLat = toRad(lat2 - lat1);\n  const dLon = toRad(lon2 - lon1);\n  const a =\n    Math.sin(dLat / 2) ** 2 +\n    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;\n  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n}\n\nfunction angularDiff(a, b) {\n  const diff = Math.abs(a - b) % 360;\n  return diff > 180 ? 360 - diff : diff;\n}\n\nfunction compass(deg) {\n  const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];\n  return points[Math.round(deg / 45) % 8];\n}\n\nconst threats = [];\nfor (const occ of occurrences) {\n  const lat = occ.decimalLatitude;\n  const lon = occ.decimalLongitude;\n  if (lat === undefined || lon === undefined) continue;\n\n  const distance = distanceKm(farmLat, farmLon, lat, lon);\n  const bearingToFarm = bearing(lat, lon, farmLat, farmLon);\n  const diff = angularDiff(bearingToFarm, windBlowingToward);\n  const windAligned = diff <= toleranceDeg;\n\n  threats.push({\n    species: occ.species || occ.scientificName || 'Unknown species',\n    establishmentMeans: occ.establishmentMeans || 'UNKNOWN',\n    locality: occ.locality || occ.stateProvince || occ.country || 'Unknown locality',\n    eventDate: occ.eventDate || occ.year || 'Unknown date',\n    distance_km: Math.round(distance * 10) / 10,\n    bearing_to_farm_deg: Math.round(bearingToFarm),\n    wind_alignment_diff_deg: Math.round(diff),\n    wind_aligned: windAligned,\n    upwind_edge_to_inspect: compass(avgWindFrom),\n  });\n}\n\nconst alignedThreats = threats\n  .filter((t) => t.wind_aligned)\n  .sort((a, b) => a.distance_km - b.distance_km);\n\nreturn [\n  {\n    json: {\n      farm_latitude: farmLat,\n      farm_longitude: farmLon,\n      avg_wind_from_deg: Math.round(avgWindFrom),\n      avg_wind_toward_deg: Math.round(windBlowingToward),\n      avg_wind_speed_kmh: Math.round(avgWindSpeed * 10) / 10,\n      total_occurrences_checked: occurrences.length,\n      aligned_threat_count: alignedThreats.length,\n      aligned_threats: alignedThreats,\n      all_threats: threats,\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "abe9b906-0649-4d7b-9f83-9657e81ab62c",
      "name": "Check Wind Alignment Threat",
      "type": "n8n-nodes-base.if",
      "position": [
        1952,
        176
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "35dd836e-9fe7-4b33-91ce-ff2cb5e37d56",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.aligned_threat_count }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "90813279-95d2-44e2-9f0a-2b9d985e820d",
      "name": "Scout Alert Generator Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2208,
        192
      ],
      "parameters": {
        "text": "=You are an agricultural biosecurity analyst writing an URGENT field alert for crop scouts.\n\nWind-vector threat data (JSON):\n{{ JSON.stringify($json, null, 2) }}\n\nWrite a Slack message for the field scout channel. Rules:\n- Only reference occurrences in \"aligned_threats\" \u2014 these are the ones the wind is actively carrying toward the farm.\n- Assign a threat level per occurrence: CRITICAL if distance_km < 25 and avg_wind_speed_kmh > 15; HIGH if distance_km < 50; MEDIUM otherwise.\n- Open with a one-line summary (count of threats, worst threat level).\n- For each aligned threat, give: species name, distance from farm, which compass edge to inspect first (upwind_edge_to_inspect), and the threat level.\n- Close with a single clear action instruction: prioritize inspecting the named upwind edge(s) in the next scouting pass.\n- Use Slack markdown (bold with *asterisks*, bullet points with -). Keep it under 150 words. No preamble, no sign-off \u2014 output ONLY the message text.",
        "options": {
          "systemMessage": "You are a precise, no-nonsense agricultural biosecurity analyst. You never invent data that isn't in the input JSON."
        },
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "909fd66b-6cca-48b0-b850-9a63d3117edb",
      "name": "Gemini LLM Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatGoogleGemini",
      "position": [
        2176,
        400
      ],
      "parameters": {
        "options": {},
        "modelName": "models/gemini-3.1-flash-lite"
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "63a28ac0-07e4-48cc-b5ec-c37bd1f8d68e",
      "name": "Send Slack Alert to Scouts",
      "type": "n8n-nodes-base.slack",
      "position": [
        2560,
        192
      ],
      "parameters": {
        "text": "={{ $json.output }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Search Parameters').first().json.slack_channel }}"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.3
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "aa7c9c87-b543-435b-beff-c883a5ec03da",
  "nodeGroups": [],
  "connections": {
    "Fetch Wind Data": {
      "main": [
        [
          {
            "node": "Calculate Wind Threat Alignment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini LLM Model": {
      "ai_languageModel": [
        [
          {
            "node": "Scout Alert Generator Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Every Morning at 6am": {
      "main": [
        [
          {
            "node": "Set Search Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Search Parameters": {
      "main": [
        [
          {
            "node": "Fetch GBIF Pest Occurrences",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Nearby Pests": {
      "main": [
        [],
        [
          {
            "node": "Fetch Wind Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Wind Alignment Threat": {
      "main": [
        [],
        [
          {
            "node": "Scout Alert Generator Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch GBIF Pest Occurrences": {
      "main": [
        [
          {
            "node": "Check for Nearby Pests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scout Alert Generator Agent": {
      "main": [
        [
          {
            "node": "Send Slack Alert to Scouts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Wind Threat Alignment": {
      "main": [
        [
          {
            "node": "Check Wind Alignment Threat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}