DEV Community

shashank ms
shashank ms

Posted on

Applying LLMs to Environmental Science: A Guide

We are going to build an environmental field-note analyzer that reads unstructured observation logs, extracts structured water-quality metrics, flags EPA threshold violations, and drafts compliance summaries. It helps environmental consultants and field technicians turn raw notebook entries into audit-ready documentation in seconds.

What you'll need

Step 1: Scaffold the client and data structures

First, initialize the Oxlo.ai client and define a simple thresholds dictionary. I keep the EPA limits in plain Python so we can audit them without guessing.

from openai import OpenAI
import json

client = OpenAI(
    base_url="https://api.oxlo.ai/v1",
    api_key="YOUR_OXLO_API_KEY"
)

THRESHOLDS = {
    "ph": {"min": 6.5, "max": 8.5, "unit": "SU"},
    "dissolved_oxygen": {"min": 5.0, "unit": "mg/L"},
    "turbidity": {"max": 10.0, "unit": "NTU"},
    "temperature": {"max": 20.0, "unit": "°C"},
}

Step 2: Write the extraction prompt

The system prompt below forces structured JSON output from messy field text. I run it against llama-3.3-70b because it handles long, unstructured narratives reliably.

SYSTEM_PROMPT = """You are an environmental data extraction assistant. Read the user's field note and return a JSON object with exactly these keys:
- site_id: string
- date: string in ISO 8601 format
- parameters: list of objects, each with name, value, unit
- anomalies: list of strings describing anything unusual
- notes: string with any other relevant context

Use standard parameter names: ph, dissolved_oxygen, turbidity, temperature, conductivity, total_dissolved_solids. Convert values to standard metric units. If a parameter is missing, omit it. Respond with valid JSON only."""

def extract_field_notes(raw_text: str) -> dict:
    response = client.chat.completions.create(
        model="llama-3.3-70b",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": raw_text},
        ],
        response_format={"type": "json_object"},
    )
    return json.loads(response.choices[0].message.content)

Step 3: Add regulatory threshold checking

Next, compare the extracted parameters against our thresholds dictionary. This step stays in pure Python so the logic is deterministic and fast.

def check_violations(extracted: dict) -> list:
    violations = []
    for param in extracted.get("parameters", []):
        name = param["name"].lower()
        if name not in THRESHOLDS:
            continue
        rule = THRESHOLDS[name]
        value = param["value"]
        if "min" in rule and value < rule["min"]:
            violations.append({
                "parameter": name,
                "value": value,
                "unit": param.get("unit", rule["unit"]),
                "breach": f"below minimum {rule['min']} {rule['unit']}",
            })
        if "max" in rule and value > rule["max"]:
            violations.append({
                "parameter": name,
                "value": value,
                "unit": param.get("unit", rule["unit"]),
                "breach": f"above maximum {rule['max']} {rule['unit']}",
            })
    return violations

Step 4: Generate the compliance summary

Now we feed the structured findings into a second LLM call. I use kimi-k2.6 here because its reasoning and prose generation produce concise, regulator-ready language.

def draft_summary(extracted: dict, violations: list) -> str:
    payload = {
        "site_id": extracted.get("site_id"),
        "date": extracted.get("date"),
        "parameters": extracted.get("parameters"),
        "violations": violations,
        "anomalies": extracted.get("anomalies", []),
    }
    user_msg = (
        "Draft a concise compliance summary from this structured data: "
        f"{json.dumps(payload, indent=2)}"
    )

    response = client.chat.completions.create(
        model="kimi-k2.6",
        messages=[
            {"role": "system", "content": "You are an environmental compliance officer. Write a brief, factual summary suitable for a regulatory filing. List any threshold violations clearly. Keep it under 200 words."},
            {"role": "user", "content": user_msg},
        ],
    )
    return response.choices[0].message.content

Step 5: Assemble the agent

Finally, wrap the pipeline in a single class. One method ingests raw text and returns the extraction, violations, and summary together.

class EnvironmentalLogAgent:
    def __init__(self):
        self.client = client

    def analyze(self, raw_log: str) -> dict:
        extracted = extract_field_notes(raw_log)
        violations = check_violations(extracted)
        summary = draft_summary(extracted, violations)
        return {
            "extracted": extracted,
            "violations": violations,
            "summary": summary,
        }

Run it

Here is a realistic field note. Pass it through the agent and inspect the output.

raw_log = """
Site: MW-12
Date: 2024-09-18
Observer: J. Chen
Weather: Overcast, 14C
Stream looked cloudy. pH probe read 8.9. DO meter showing 4.2 mg/L. 
Turbidity was really high, maybe 35 NTU on the meter. 
Saw some foam near the outfall. No smell.
"""

agent = EnvironmentalLogAgent()
result = agent.analyze(raw_log)
print(json.dumps(result, indent=2))

Expected output:

{
  "extracted": {
    "site_id": "MW-12",
    "date": "2024-09-18",
    "parameters": [
      {"name": "ph", "value": 8.9, "unit": "SU"},
      {"name": "dissolved_oxygen", "value": 4.2, "unit": "mg/L"},
      {"name": "turbidity", "value": 35, "unit": "NTU"},
      {"name": "temperature", "value": 14, "unit": "°C"}
    ],
    "anomalies": ["cloudy stream", "foam near outfall"],
    "notes": "No smell reported."
  },
  "violations": [
    {"parameter": "ph", "value": 8.9, "unit": "SU", "breach": "above maximum 8.5 SU"},
    {"parameter": "dissolved_oxygen", "value": 4.2, "unit": "mg/L", "breach": "below minimum 5.0 mg/L"},
    {"parameter": "turbidity", "value": 35, "unit": "NTU", "breach": "above maximum 10.0 NTU"}
  ],
  "summary": "On 2024-09-18, monitoring site MW-12 recorded three parameter excursions. The pH reading of 8.9 SU exceeds the upper threshold of 8.5 SU. Dissolved oxygen at 4.2 mg/L falls below the 5.0 mg/L minimum. Turbidity measured 35 NTU, well above the 10.0 NTU limit. Visual anomalies included cloudy water and foam near the outfall. These findings suggest a potential discharge event requiring follow-up sampling within 24 hours."
}

Because Oxlo.ai charges a flat rate per request, running this two-step pipeline on long field narratives costs the same whether the log is two sentences or two pages. For teams processing hundreds of daily logs, that predictability matters. You can view current plans at https://oxlo.ai/pricing.

Next steps

Connect this agent to an email trigger or SMS gateway so it automatically alerts the lead technician whenever violations is non-empty. If you want to batch-process historical archives, swap the extraction model to deepseek-v3.2, which is available on Oxlo.ai's free tier and handles large document contexts efficiently.

Top comments (0)