Skip to content

Commit f40585e

Browse files
CreatmanCEOclaude
andcommitted
ci: split CI into Tests + Lint workflows, fix CI failures
Why CI was failing: - ci.yml ran pytest without first generating data/observations/*.csv (those CSVs are gitignored — only wells.geojson is tracked) - ruff check + format had 46 lint + 51 format errors on existing code - E402 false positives on test files that intentionally set os.environ.setdefault() before module imports Fixes: - Split workflows: .github/workflows/test.yml (pytest) + lint.yml (ruff) → README gets two independent dynamic badges - test.yml: pip cache, generate-data step, dummy API keys via env - lint.yml: ruff check + ruff format --check - backend/ruff.toml: line-length 100, select E F I W, ignore E501, per-file-ignore E402 in tests/ - ruff format applied across backend/ (51 files reformatted) - ruff check --fix removed 27 unused imports / dead vars - Manually fixed 1 F841 in test_analyze_interference (added missing r30.t_days assertion) - README: replaced single ci.yml badge + hardcoded "tests-154-passing" shield with two live workflow badges (Tests + Lint) Tests: 154 passed, 0 regressions. Ruff check + format both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3920141 commit f40585e

47 files changed

Lines changed: 1134 additions & 489 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 0 additions & 25 deletions
This file was deleted.

.github/workflows/lint.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Lint
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
ruff:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.12'
17+
- name: Install ruff
18+
run: pip install ruff
19+
- name: Lint check
20+
working-directory: backend
21+
run: ruff check .
22+
- name: Format check
23+
working-directory: backend
24+
run: ruff format --check .

.github/workflows/test.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
pytest:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.12'
17+
cache: 'pip'
18+
cache-dependency-path: backend/requirements.txt
19+
- name: Install dependencies
20+
run: pip install -r backend/requirements.txt
21+
- name: Generate synthetic data (wells GeoJSON is committed; observations CSV are not)
22+
working-directory: backend
23+
run: |
24+
python -m data_generator.generate_wells
25+
python -m data_generator.generate_timeseries
26+
- name: Run pytest
27+
working-directory: backend
28+
env:
29+
GEMINI_API_KEY: test-key
30+
OPENROUTER_API_KEY: test-key
31+
run: python -m pytest tests/ -v --tb=short

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# HydroWatch
22

3-
[![CI](https://github.com/CreatmanCEO/hydrowatch/actions/workflows/ci.yml/badge.svg)](https://github.com/CreatmanCEO/hydrowatch/actions)
3+
[![Tests](https://github.com/CreatmanCEO/hydrowatch/actions/workflows/test.yml/badge.svg)](https://github.com/CreatmanCEO/hydrowatch/actions/workflows/test.yml)
4+
[![Lint](https://github.com/CreatmanCEO/hydrowatch/actions/workflows/lint.yml/badge.svg)](https://github.com/CreatmanCEO/hydrowatch/actions/workflows/lint.yml)
45
[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://python.org)
56
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115-009688.svg)](https://fastapi.tiangolo.com)
67
[![Next.js 15](https://img.shields.io/badge/Next.js-15-black.svg)](https://nextjs.org)
78
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8-
[![Tests](https://img.shields.io/badge/tests-154%20passing-brightgreen.svg)](#testing)
99

1010
**Theis-based groundwater monitoring with LLM assistant for well interference and depression cone analysis in Abu Dhabi aquifer systems.**
1111

backend/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Application configuration via environment variables."""
2+
23
from pathlib import Path
4+
5+
from pydantic import SecretStr
36
from pydantic_settings import BaseSettings
4-
from pydantic import SecretStr, Field
57

68

79
class Settings(BaseSettings):

backend/data_generator/generate_timeseries.py

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Generate synthetic time series with anomaly injection for groundwater wells."""
2+
23
import json
3-
import os
44
from datetime import datetime, timedelta
55
from pathlib import Path
66

@@ -178,59 +178,64 @@ def generate_well_timeseries(
178178
data["debit_ls"] = AnomalyInjector.gradual_decline(
179179
data["debit_ls"], start, duration, decline_pct
180180
)
181-
anomaly_log.append({
182-
"type": "debit_decline",
183-
"start_idx": start,
184-
"duration": duration,
185-
"decline_pct": round(decline_pct, 3),
186-
"parameter": "debit_ls",
187-
})
181+
anomaly_log.append(
182+
{
183+
"type": "debit_decline",
184+
"start_idx": start,
185+
"duration": duration,
186+
"decline_pct": round(decline_pct, 3),
187+
"parameter": "debit_ls",
188+
}
189+
)
188190

189191
elif atype == "tds_spike":
190192
spike_factor = float(rng.uniform(1.5, 3.0))
191193
spike_dur = max(duration // 3, 10)
192194
data["tds_mgl"] = AnomalyInjector.sudden_spike(
193195
data["tds_mgl"], start, spike_dur, spike_factor
194196
)
195-
anomaly_log.append({
196-
"type": "tds_spike",
197-
"start_idx": start,
198-
"duration": spike_dur,
199-
"spike_factor": round(spike_factor, 2),
200-
"parameter": "tds_mgl",
201-
})
197+
anomaly_log.append(
198+
{
199+
"type": "tds_spike",
200+
"start_idx": start,
201+
"duration": spike_dur,
202+
"spike_factor": round(spike_factor, 2),
203+
"parameter": "tds_mgl",
204+
}
205+
)
202206

203207
elif atype == "sensor_fault":
204208
fault_dur = max(duration // 5, 5)
205209
data["ph"] = AnomalyInjector.sensor_fault(
206210
data["ph"], start, fault_dur, fault_value=0.0
207211
)
208-
anomaly_log.append({
209-
"type": "sensor_fault",
210-
"start_idx": start,
211-
"duration": fault_dur,
212-
"fault_value": 0.0,
213-
"parameter": "ph",
214-
})
212+
anomaly_log.append(
213+
{
214+
"type": "sensor_fault",
215+
"start_idx": start,
216+
"duration": fault_dur,
217+
"fault_value": 0.0,
218+
"parameter": "ph",
219+
}
220+
)
215221

216222
# Build timestamps
217223
start_date = datetime(2024, 1, 1)
218224
hours_step = 24 // measurements_per_day
219-
timestamps = [
220-
start_date + timedelta(hours=i * hours_step)
221-
for i in range(n_points)
222-
]
223-
224-
df = pd.DataFrame({
225-
"timestamp": timestamps,
226-
"well_id": well_id,
227-
"debit_ls": np.round(data["debit_ls"], 3),
228-
"tds_mgl": np.round(data["tds_mgl"], 1),
229-
"ph": np.round(data["ph"], 2),
230-
"chloride_mgl": np.round(data["chloride_mgl"], 1),
231-
"water_level_m": np.round(data["water_level_m"], 2),
232-
"temperature_c": np.round(data["temperature_c"], 1),
233-
})
225+
timestamps = [start_date + timedelta(hours=i * hours_step) for i in range(n_points)]
226+
227+
df = pd.DataFrame(
228+
{
229+
"timestamp": timestamps,
230+
"well_id": well_id,
231+
"debit_ls": np.round(data["debit_ls"], 3),
232+
"tds_mgl": np.round(data["tds_mgl"], 1),
233+
"ph": np.round(data["ph"], 2),
234+
"chloride_mgl": np.round(data["chloride_mgl"], 1),
235+
"water_level_m": np.round(data["water_level_m"], 2),
236+
"temperature_c": np.round(data["temperature_c"], 1),
237+
}
238+
)
234239

235240
return df, anomaly_log
236241

@@ -274,9 +279,7 @@ def main():
274279
well_ids = [f["properties"]["id"] for f in geojson["features"]]
275280
obs_dir = data_dir / "observations"
276281

277-
result = generate_all_timeseries(
278-
well_ids, days=365, output_dir=str(obs_dir)
279-
)
282+
result = generate_all_timeseries(well_ids, days=365, output_dir=str(obs_dir))
280283
print(f"Generated {len(result)} time series -> observations/")
281284

282285

backend/data_generator/generate_wells.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,58 @@
11
"""Generate synthetic groundwater well GeoJSON for Abu Dhabi region."""
2+
23
import json
3-
import os
44
from pathlib import Path
55

66
import numpy as np
77

88
# Abu Dhabi well field clusters (center lat, lon, name)
99
CLUSTERS = [
10-
{"id": "AL_WATHBA", "name": "Al Wathba", "center": (24.42, 54.72), "radius_deg": 0.06},
11-
{"id": "MUSSAFAH", "name": "Mussafah Industrial", "center": (24.35, 54.50), "radius_deg": 0.04},
10+
{
11+
"id": "AL_WATHBA",
12+
"name": "Al Wathba",
13+
"center": (24.42, 54.72),
14+
"radius_deg": 0.06,
15+
},
16+
{
17+
"id": "MUSSAFAH",
18+
"name": "Mussafah Industrial",
19+
"center": (24.35, 54.50),
20+
"radius_deg": 0.04,
21+
},
1222
{"id": "SWEIHAN", "name": "Sweihan", "center": (24.48, 55.35), "radius_deg": 0.08},
13-
{"id": "AL_KHATIM", "name": "Al Khatim", "center": (24.28, 55.10), "radius_deg": 0.07},
23+
{
24+
"id": "AL_KHATIM",
25+
"name": "Al Khatim",
26+
"center": (24.28, 55.10),
27+
"radius_deg": 0.07,
28+
},
1429
]
1530

1631
AQUIFER_TYPES = [
17-
{"name": "Dammam Limestone", "depth_range": (80, 200), "T_range": (200, 1200), "S_range": (0.002, 0.008)},
18-
{"name": "Umm Er Radhuma", "depth_range": (150, 350), "T_range": (100, 800), "S_range": (0.001, 0.005)},
19-
{"name": "Quaternary Sand", "depth_range": (30, 80), "T_range": (50, 400), "S_range": (0.01, 0.05)},
20-
{"name": "Alluvial", "depth_range": (20, 60), "T_range": (30, 300), "S_range": (0.05, 0.15)},
32+
{
33+
"name": "Dammam Limestone",
34+
"depth_range": (80, 200),
35+
"T_range": (200, 1200),
36+
"S_range": (0.002, 0.008),
37+
},
38+
{
39+
"name": "Umm Er Radhuma",
40+
"depth_range": (150, 350),
41+
"T_range": (100, 800),
42+
"S_range": (0.001, 0.005),
43+
},
44+
{
45+
"name": "Quaternary Sand",
46+
"depth_range": (30, 80),
47+
"T_range": (50, 400),
48+
"S_range": (0.01, 0.05),
49+
},
50+
{
51+
"name": "Alluvial",
52+
"depth_range": (20, 60),
53+
"T_range": (30, 300),
54+
"S_range": (0.05, 0.15),
55+
},
2156
]
2257

2358
OPERATORS = [
@@ -27,7 +62,14 @@
2762
"Abu Dhabi Agriculture Authority",
2863
]
2964

30-
STATUSES = ["active", "active", "active", "active", "inactive", "maintenance"] # weighted
65+
STATUSES = [
66+
"active",
67+
"active",
68+
"active",
69+
"active",
70+
"inactive",
71+
"maintenance",
72+
] # weighted
3173

3274

3375
def generate_wells_geojson(
@@ -79,10 +121,10 @@ def generate_wells_geojson(
79121
status = str(rng.choice(STATUSES))
80122
operator = str(rng.choice(OPERATORS))
81123
install_year = int(rng.integers(1995, 2023))
82-
install_date = f"{install_year}-{rng.integers(1,13):02d}-{rng.integers(1,29):02d}"
124+
install_date = f"{install_year}-{rng.integers(1, 13):02d}-{rng.integers(1, 29):02d}"
83125

84-
well_id = f"AUH-{ci+1:02d}-{j+1:03d}"
85-
name_en = f"{cluster['name']} Well {j+1}"
126+
well_id = f"AUH-{ci + 1:02d}-{j + 1:03d}"
127+
name_en = f"{cluster['name']} Well {j + 1}"
86128

87129
feature = {
88130
"type": "Feature",

backend/data_generator/hydro_models.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
"""Analytical groundwater models: Theis equation, superposition."""
2+
23
from dataclasses import dataclass
4+
35
import numpy as np
46
from scipy.special import exp1
57

68

79
@dataclass
810
class PumpingWell:
911
"""A pumping well with hydraulic parameters."""
12+
1013
id: str
11-
x: float # UTM easting, meters
12-
y: float # UTM northing, meters
13-
Q: float # pumping rate, m3/day
14-
T: float # transmissivity, m2/day
15-
S: float # storativity (dimensionless)
16-
start_time: float # pumping start time, days
14+
x: float # UTM easting, meters
15+
y: float # UTM northing, meters
16+
Q: float # pumping rate, m3/day
17+
T: float # transmissivity, m2/day
18+
S: float # storativity (dimensionless)
19+
start_time: float # pumping start time, days
1720

1821

1922
def theis_drawdown(Q: float, T: float, S: float, r: float, t: float) -> float:

backend/db/seed.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Seed database from generated GeoJSON and CSV files."""
2+
23
import asyncio
34
import json
45
from pathlib import Path
56

67
import pandas as pd
7-
from sqlalchemy import delete
8-
from sqlalchemy.ext.asyncio import AsyncSession
98
from geoalchemy2.shape import from_shape
109
from shapely.geometry import shape
10+
from sqlalchemy import delete
11+
from sqlalchemy.ext.asyncio import AsyncSession
1112

12-
from models.database import Base, Well, Observation
1313
from db.session import get_engine, get_session_factory
14+
from models.database import Base, Observation, Well
1415

1516

1617
async def seed_wells(session: AsyncSession, geojson_path: str):

backend/db/session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Async SQLAlchemy session factory — lazy initialization."""
2-
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
2+
3+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
34

45
_engine = None
56
_session_factory = None
@@ -9,6 +10,7 @@ def get_engine():
910
global _engine
1011
if _engine is None:
1112
from config import get_settings
13+
1214
_engine = create_async_engine(get_settings().database_url, echo=False)
1315
return _engine
1416

0 commit comments

Comments
 (0)