diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..134bb92 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Git +.git +.gitignore + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.pytest_cache/ +.venv/ +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Database (for local development) +*.db +*.sqlite +*.sqlite3 + +# Jupyter +.ipynb_checkpoints/ +*.ipynb + +# MATLAB autosaves +*.asv +*.autosave + +# Documentation +docs/ +*.md +!README.md + +# Testing +.coverage +htmlcov/ + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Design docs +PRD* diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..4939e73 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,37 @@ +# GitHub Actions workflow to build and test Docker image +name: Docker Build + +on: + push: + branches: [ main, claude/* ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build Docker image + run: docker build -t ts-errors-api:test . + + - name: Test Docker image + run: | + docker run -d -p 8000:8000 --name test-api ts-errors-api:test + sleep 5 + curl -f http://localhost:8000/health || exit 1 + docker stop test-api + + - name: Login to Docker Hub (on main branch) + if: github.ref == 'refs/heads/main' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Push to Docker Hub (on main branch) + if: github.ref == 'refs/heads/main' + run: | + docker tag ts-errors-api:test ${{ secrets.DOCKER_USERNAME }}/ts-errors-api:latest + docker push ${{ secrets.DOCKER_USERNAME }}/ts-errors-api:latest diff --git a/.gitignore b/.gitignore index b6d1795..c75305e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ PRD* +# Database files +*.db +*.sqlite +*.sqlite3 +ts_analysis.db + # --- Python --- __pycache__/ *.py[cod] diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..da437a1 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,221 @@ +# Quick Deployment Guide - Free Tier + +## πŸš€ Deploy in 5 Minutes! + +### Prerequisites +- GitHub account +- Vercel account (free): https://vercel.com +- Render account (free): https://render.com + +--- + +## Step 1: Deploy Backend to Render + +### Option A: One-Click Deploy (Easiest) +1. Fork this repository to your GitHub account +2. Go to https://render.com/deploy +3. Click "New" β†’ "Blueprint" +4. Connect your GitHub repository +5. Select `TS-ErrorsAnalysis` repo +6. Click "Apply" - Render will read `render.yaml` and deploy automatically! + +### Option B: Manual Deploy +1. Go to https://dashboard.render.com +2. Click "New +" β†’ "Web Service" +3. Connect your GitHub repo +4. Configure: + - **Name**: `ts-errors-api` + - **Runtime**: Python 3 + - **Build Command**: `pip install -r requirements.txt -r api/requirements.txt` + - **Start Command**: `uvicorn api.main:app --host 0.0.0.0 --port $PORT` + - **Plan**: Free +5. Add Environment Variable: + - `DATABASE_URL`: (will set up database next) +6. Click "Create Web Service" + +### Add PostgreSQL Database (Optional - or use SQLite) +1. In Render Dashboard, click "New +" β†’ "PostgreSQL" +2. Name: `ts-errors-db` +3. Plan: Free +4. Create Database +5. Copy the "Internal Database URL" +6. Go back to your Web Service β†’ Environment +7. Set `DATABASE_URL` to the database URL +8. Your API will restart automatically + +### Get Your Backend URL +- After deployment: `https://ts-errors-api.onrender.com` +- Test it: `https://ts-errors-api.onrender.com/health` +- API Docs: `https://ts-errors-api.onrender.com/docs` + +**⚠️ Note**: Free tier sleeps after 15 min of inactivity. First request may take 30 seconds to wake up. + +--- + +## Step 2: Deploy Frontend to Vercel + +### Option A: One-Click Deploy +1. Go to https://vercel.com/new +2. Import your GitHub repository +3. Select `TS-ErrorsAnalysis` repo +4. Configure: + - **Framework Preset**: Vite + - **Root Directory**: `frontend` + - **Build Command**: `npm run build` + - **Output Directory**: `dist` +5. Add Environment Variable: + - **Name**: `VITE_API_URL` + - **Value**: `https://ts-errors-api.onrender.com` (your Render backend URL) +6. Click "Deploy" + +### Option B: Vercel CLI +```bash +cd frontend +npm install -g vercel +vercel + +# Follow prompts: +# - Link to existing project? No +# - Project name: ts-errors-analysis +# - Directory: ./ +# - Override settings? No + +# Add environment variable +vercel env add VITE_API_URL production +# Enter: https://ts-errors-api.onrender.com +``` + +### Get Your Frontend URL +- Your app: `https://ts-errors-analysis.vercel.app` +- Custom domain available on free tier! + +--- + +## Step 3: Test Your Deployment + +1. Open your Vercel URL +2. Go to "Analyze" page +3. Enter test data: + ``` + Predicted: 1, 2, 3, 4, 5 + Target: 1.1, 2.2, 2.9, 4.1, 4.8 + ``` +4. Click "Analyze" +5. View results! + +--- + +## 🎯 Your Live URLs + +| Service | URL | Purpose | +|---------|-----|---------| +| **Frontend** | `https://your-app.vercel.app` | Main web interface | +| **API** | `https://your-api.onrender.com` | Backend API | +| **API Docs** | `https://your-api.onrender.com/docs` | Interactive API docs | +| **Database** | (Internal) | PostgreSQL on Render | + +--- + +## πŸ”§ Alternative: Railway (Another Free Option) + +Railway is even easier than Render: + +1. Go to https://railway.app +2. Click "Start a New Project" +3. Select "Deploy from GitHub repo" +4. Choose `TS-ErrorsAnalysis` +5. Railway auto-detects everything! +6. Add environment variables in dashboard +7. Done! + +Railway advantages: +- Faster wake-up (no sleep on free tier) +- Simpler interface +- Auto-deploys on git push + +--- + +## πŸ“ Environment Variables Reference + +### Backend (`render.yaml` or Railway) +```bash +DATABASE_URL=postgresql://user:pass@host/db # Optional, uses SQLite if not set +PYTHONUNBUFFERED=1 +PORT=8000 # Render/Railway set this automatically +``` + +### Frontend (Vercel) +```bash +VITE_API_URL=https://your-backend-url.onrender.com +``` + +--- + +## πŸ”„ Continuous Deployment + +Both platforms auto-deploy when you push to GitHub: + +```bash +# Make changes +git add . +git commit -m "Update feature" +git push origin claude/add-claude-documentation-d60DI + +# Vercel & Render automatically deploy! +``` + +--- + +## πŸ’° Cost Breakdown (All FREE!) + +| Service | Plan | Limits | +|---------|------|--------| +| **Vercel** | Hobby (Free) | 100GB bandwidth, unlimited sites | +| **Render** | Free | 750 hours/month, sleeps after 15min | +| **Railway** | Trial | $5 credit/month (enough for small apps) | +| **Database** | Render Free | 1GB storage, 1 concurrent connection | + +--- + +## 🚨 Troubleshooting + +### Backend won't start +- Check logs in Render dashboard +- Verify `requirements.txt` paths are correct +- Ensure Python version is 3.11+ + +### Frontend can't connect to backend +- Verify `VITE_API_URL` environment variable +- Check CORS settings (already configured in our API) +- Try API directly: `https://your-api.onrender.com/health` + +### Database connection issues +- Check `DATABASE_URL` format +- Falls back to SQLite if not set (works fine!) +- Free tier PostgreSQL is optional + +--- + +## βœ… Success Checklist + +- [ ] Backend deployed to Render +- [ ] Backend health check returns 200: `/health` +- [ ] API docs accessible: `/docs` +- [ ] Frontend deployed to Vercel +- [ ] Frontend loads in browser +- [ ] Can run analysis from frontend +- [ ] Statistics page shows data +- [ ] Tools page works + +--- + +## πŸŽ‰ Next Steps + +Once deployed: +1. Share your URL with colleagues +2. Run real hydrological analyses +3. Customize branding (Stage 6+) +4. Add custom domain (free on Vercel) +5. Monitor usage in dashboards + +**Need help?** Check logs in Render/Vercel dashboards or ask for assistance! diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..03acaef --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,184 @@ +# Docker Deployment Guide + +## Quick Start + +### Using Make (Recommended) +```bash +# Build the image +make build + +# Run in development mode +make run + +# View logs +make logs + +# Stop containers +make stop +``` + +### Using Docker Compose Directly +```bash +# Development mode (with hot reload) +docker-compose up -d + +# Production mode +docker-compose -f docker-compose.prod.yml up -d + +# Stop +docker-compose down +``` + +### Using Docker Only +```bash +# Build +docker build -t ts-errors-api . + +# Run +docker run -d -p 8000:8000 --name ts-errors ts-errors-api +``` + +## Access Points + +Once running: +- **API**: http://localhost:8000 +- **Interactive Docs**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **Health Check**: http://localhost:8000/health + +## Environment Variables + +Set these in `docker-compose.yml` or via `-e` flag: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `sqlite:///./ts_analysis.db` | Database connection string | +| `PYTHONUNBUFFERED` | `1` | Python logging output | + +## Data Persistence + +The SQLite database is persisted in the `./data` directory: +```bash +ls -la data/ +# Should show ts_analysis.db +``` + +## Development Workflow + +1. **Start containers**: + ```bash + make run + ``` + +2. **Make code changes** - changes auto-reload + +3. **View logs**: + ```bash + make logs + ``` + +4. **Open shell for debugging**: + ```bash + make shell + python + >>> from api.main import app + ``` + +5. **Stop when done**: + ```bash + make stop + ``` + +## Production Deployment + +### Local Production Mode +```bash +make run-prod +``` + +### Google Cloud Run + +1. **Build and tag**: + ```bash + docker build -t gcr.io/YOUR-PROJECT/ts-errors-api . + ``` + +2. **Push to GCR**: + ```bash + docker push gcr.io/YOUR-PROJECT/ts-errors-api + ``` + +3. **Deploy**: + ```bash + gcloud run deploy ts-errors-api \ + --image gcr.io/YOUR-PROJECT/ts-errors-api \ + --platform managed \ + --region us-central1 \ + --allow-unauthenticated \ + --set-env-vars DATABASE_URL=postgresql://... + ``` + +### Docker Hub +```bash +# Login +docker login + +# Tag +docker tag ts-errors-api yourusername/ts-errors-api:latest + +# Push +docker push yourusername/ts-errors-api:latest +``` + +## Troubleshooting + +### Container won't start +```bash +# Check logs +docker-compose logs api + +# Check health +docker ps +``` + +### Database issues +```bash +# Reset database +make clean +make run +``` + +### Port already in use +```bash +# Change port in docker-compose.yml +ports: + - "8080:8000" # Use 8080 instead +``` + +### Hot reload not working +Make sure volume mounts are correct in `docker-compose.yml`: +```yaml +volumes: + - ./src:/app/src + - ./api:/app/api +``` + +## Multi-Stage Build + +The Dockerfile uses multi-stage builds for smaller images: +- **Builder stage**: Installs all dependencies +- **Production stage**: Only runtime dependencies +- Final image: ~200MB + +## Health Checks + +Container includes health check that: +- Runs every 30 seconds +- Calls `/health` endpoint +- Marks unhealthy after 3 failures +- Useful for orchestration (Kubernetes, Cloud Run) + +## Next Steps + +- Stage 4: Add React frontend container +- Stage 8: Configure for Google Cloud Run with managed PostgreSQL diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca88ed1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Multi-stage build for efficient container +FROM python:3.11-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies +COPY requirements.txt api/requirements.txt ./ +RUN pip install --user --no-cache-dir -r requirements.txt -r api/requirements.txt + +# Production stage +FROM python:3.11-slim + +WORKDIR /app + +# Copy dependencies from builder +COPY --from=builder /root/.local /root/.local + +# Copy application code +COPY src/ ./src/ +COPY api/ ./api/ +COPY LICENSE README.md ./ + +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=2)" || exit 1 + +# Run the application +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a48e592 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# Makefile for TS-ErrorsAnalysis + +.PHONY: help build run stop clean test logs shell + +help: + @echo "TS-ErrorsAnalysis - Docker Commands" + @echo "" + @echo " make build - Build Docker image" + @echo " make run - Run containers (development mode)" + @echo " make run-prod - Run containers (production mode)" + @echo " make stop - Stop containers" + @echo " make clean - Remove containers and images" + @echo " make logs - View container logs" + @echo " make shell - Open shell in API container" + @echo " make test - Run tests" + @echo "" + +build: + docker-compose build + +run: + docker-compose up -d + @echo "API running at http://localhost:8000" + @echo "API docs at http://localhost:8000/docs" + +run-prod: + docker-compose -f docker-compose.prod.yml up -d + @echo "Production API running at http://localhost:8000" + +stop: + docker-compose down + docker-compose -f docker-compose.prod.yml down 2>/dev/null || true + +clean: stop + docker-compose down -v --rmi local + rm -rf data/*.db + +logs: + docker-compose logs -f + +shell: + docker-compose exec api /bin/bash + +test: + docker-compose exec api python -m pytest tests/ + +restart: stop run + +# Development helpers +dev-install: + pip install -r requirements.txt -r api/requirements.txt + +dev-run: + uvicorn api.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/README.md b/README.md index 6ac88f9..dd3c687 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,268 @@ -# Error Analysis (Hydrology) +# TS-ErrorsAnalysis -Implements common error/skill metrics for measured vs simulated time series, -including RMSE, NSE (NSC), correlation, NRMSE, coefficient of persistence, and naive (persistence) baselines. +**Hydrological time series error analysis toolkit** with FastAPI backend, React frontend, and advanced time series processing tools. -## Python +--- + +## 🌐 **Deploy Your Own (FREE - 5 Minutes)** + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/corzogac/TS-ErrorsAnalysis) + +[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/corzogac/TS-ErrorsAnalysis&project-name=ts-errors-analysis&root-directory=frontend&env=VITE_API_URL) + +**Quick Steps:** +1. Click "Deploy to Render" β†’ deploys backend + database +2. Copy your Render URL (e.g., `https://ts-errors-api.onrender.com`) +3. Click "Deploy to Vercel" β†’ deploys frontend +4. Set environment variable `VITE_API_URL` to your Render URL +5. **Done!** Your app is live πŸŽ‰ + +**Full Guide**: See [DEPLOYMENT.md](DEPLOYMENT.md) + +--- + +## ✨ Features + +### Error Analysis +- **28+ metrics**: RMSE, NSE/NSC, KGE (2009, 2012), correlation, RΒ², PBIAS, sMAPE, Index of Agreement +- **Persistence baseline**: Compare against naive lag-1 forecast +- **Hydrology-focused**: Metrics designed for hydrological applications + +### Advanced Time Series Tools +- **Interpolation**: Cubic, quadratic, linear splines +- **Smoothing**: Moving average, Savitzky-Golay, exponential +- **Decomposition**: Trend + seasonal + residual components +- **Outlier detection**: Z-score and IQR methods +- **Resampling**: Change sampling rate with interpolation + +### Modern Web Interface +- **Dashboard**: System overview with statistics +- **Analyze**: Upload data, get instant results with charts +- **Tools**: Interactive time series processing +- **History**: Browse past analyses +- **Stats**: User and system statistics + +### Developer Features +- **RESTful API** with automatic OpenAPI docs (`/docs`) +- **Docker containerized** for easy deployment +- **Database tracking** of all analyses (SQLite/PostgreSQL) +- **Session management** with user statistics +- **CORS enabled** for frontend integration + +--- + +## πŸš€ Quick Start + +### Option 1: Docker (Recommended) ```bash -pip install numpy matplotlib -python examples/demo.py +git clone https://github.com/corzogac/TS-ErrorsAnalysis.git +cd TS-ErrorsAnalysis + +# Build and run +make build +make run +# Access the app +# Frontend: http://localhost:3000 +# Backend API: http://localhost:8000 +# API Docs: http://localhost:8000/docs ``` -# TS-ErrorsAnalysis (Hydrology) -A tiny, reusable toolkit to evaluate **predicted vs. observed** time series for hydrological applications. -It computes classic metrics (RMSE, NSE/NSC, correlation, persistence skill, …) and newer ones used in hydrology (KGE, d, d₁), returns them as a single object **`R`** (dot access like `R.RMSE`), and keeps **plotting** in a separate module. +### Option 2: Manual Setup +```bash +# Backend +pip install -r requirements.txt -r api/requirements.txt +uvicorn api.main:app --reload --host 0.0.0.0 --port 8000 -- **Author:** Gerald Augusto Corzo PΓ©rez -- **Affiliation:** IHE Delft β€” Hydroinformatics (Department of Coastal & Urban Risk & Resilience) -- **License:** MIT (see `LICENSE`) -- **Citation:** Corzo PΓ©rez, G.A. (2009; updated 2025). *Error Analysis*. MATLAB Central File Exchange. +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` --- -## Why this repo? +## πŸ“Š API Examples -Researchers and students often need a **one-liner** to compute robust, interpretable skill metrics and quickly visualize what a model is doing. This repo: +### Analyze Time Series +```bash +curl -X POST http://localhost:8000/api/v1/analyze \ + -H "Content-Type: application/json" \ + -d '{ + "predicted": [1.0, 2.5, 3.2, 4.1, 5.0], + "target": [1.2, 2.3, 3.5, 3.9, 4.8], + "user_id": "researcher_123", + "analysis_name": "River Discharge Model" + }' +``` + +### Smooth Data +```bash +curl -X POST http://localhost:8000/api/v1/timeseries/smooth \ + -H "Content-Type: application/json" \ + -d '{ + "values": [1.0, 2.5, 3.2, 4.1, 5.0, 4.2, 3.8], + "method": "savitzky_golay", + "window_size": 5 + }' +``` -1. **Separates concerns** β€” `errors.py` (analysis) vs `plots.py` (visuals). -2. **Returns a single object** `R` with dot access (`R.RMSE`, `R.NSC`, …) that you can save to CSV. -3. **Includes persistence baselines** and hydrology-friendly metrics, not just generic stats. +### Get Statistics +```bash +curl http://localhost:8000/api/v1/stats/system +``` --- -## Install & run (with `uv`) +## πŸ› οΈ Tech Stack + +**Backend**: +- FastAPI (Python 3.11+) +- SQLAlchemy (ORM) +- NumPy, SciPy (numerical computing) +- Pydantic (validation) + +**Frontend**: +- React 18 + Vite +- Tailwind CSS +- Recharts (visualization) +- Axios (API client) + +**Database**: +- PostgreSQL (production) +- SQLite (development) -> You don’t have to activate a venv manually; `uv run` handles it. +**Deployment**: +- Docker + docker-compose +- Vercel (frontend) +- Render/Railway (backend) + +--- + +## πŸ“š Documentation + +- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Complete deployment guide (Vercel, Render, Railway) +- **[DOCKER.md](DOCKER.md)** - Docker setup and commands +- **[CLAUDE.md](CLAUDE.md)** - AI assistant development guide +- **[API Docs](http://localhost:8000/docs)** - Interactive API documentation (when running) + +--- + +## πŸ§ͺ Testing ```bash -# From the repo root -uv venv -uv pip install numpy matplotlib pytest -uv run python demo.py +# Run comprehensive test suite +python test_all_stages.py + +# Tests cover: +# - All API endpoints +# - Database operations +# - Time series tools +# - Error handling +# - Integration workflows +``` + +--- + +## πŸ“¦ Project Structure + +``` +TS-ErrorsAnalysis/ +β”œβ”€β”€ api/ # FastAPI backend +β”‚ β”œβ”€β”€ main.py # API endpoints +β”‚ β”œβ”€β”€ database.py # Database models +β”‚ β”œβ”€β”€ stats.py # Statistics functions +β”‚ └── timeseries.py # Time series processing +β”œβ”€β”€ frontend/ # React frontend +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ pages/ # Dashboard, Analyze, Tools, History, Stats +β”‚ β”‚ β”œβ”€β”€ services/ # API client +β”‚ β”‚ └── App.jsx # Main app component +β”‚ └── package.json +β”œβ”€β”€ src/ # Core Python modules +β”‚ β”œβ”€β”€ errors.py # Error metrics computation +β”‚ └── plots.py # Visualization +β”œβ”€β”€ matlab/ # MATLAB implementation +β”‚ └── Error1.m +β”œβ”€β”€ Dockerfile # Container image +β”œβ”€β”€ docker-compose.yml # Local development +β”œβ”€β”€ render.yaml # Render deployment config +└── test_all_stages.py # Test suite +``` + +--- + +## 🎯 Metrics Computed + +### Basic Errors +- RMSE, MAE, SSE, NRMSE + +### Model Skill +- NSC/NSE (Nash-Sutcliffe) +- Correlation (Pearson r) +- RΒ² (Coefficient of determination) +- RSR (RMSE-to-StdDev ratio) + +### Bias Metrics +- PBIAS (Percent Bias) +- sMAPE (Symmetric MAPE) +- MARE (Mean Absolute Relative Error) + +### Hydrology-Specific +- KGE2009, KGE2012 (Kling-Gupta Efficiency) +- d, d1 (Index of Agreement) + +### Persistence +- PERS (Coefficient of persistence) +- RMSEN (Naive forecast RMSE) + +--- + +## 🀝 Contributing + +Contributions welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `python test_all_stages.py` +5. Submit a pull request + +--- + +## πŸ“ License + +MIT License - See [LICENSE](LICENSE) + +--- + +## πŸ‘€ Author + +**Gerald Augusto Corzo PΓ©rez** +IHE Delft β€” Hydroinformatics +Department of Coastal & Urban Risk & Resilience + +--- + +## 🌟 Citation + +```bibtex +@software{corzo2025errors, + author = {Corzo PΓ©rez, Gerald Augusto}, + title = {TS-ErrorsAnalysis: Hydrological Time Series Error Analysis}, + year = {2025}, + publisher = {GitHub}, + url = {https://github.com/corzogac/TS-ErrorsAnalysis} +} +``` + +--- + +## πŸ’‘ Need Help? + +- πŸ“– Check [DEPLOYMENT.md](DEPLOYMENT.md) for deployment issues +- πŸ› [Open an issue](https://github.com/corzogac/TS-ErrorsAnalysis/issues) +- πŸ“§ Contact the author + +--- + +**Made with ❀️ for the hydrology community** diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..9730950 --- /dev/null +++ b/api/README.md @@ -0,0 +1,92 @@ +# TS-ErrorsAnalysis API + +FastAPI backend for hydrological time series error analysis. + +## Quick Start + +### Development Mode + +```bash +# Install dependencies +pip install -r api/requirements.txt + +# Run the API server +cd api +python main.py + +# Or with uvicorn directly +uvicorn api.main:app --reload --host 0.0.0.0 --port 8000 +``` + +The API will be available at: +- **API**: http://localhost:8000 +- **Interactive Docs**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## API Endpoints + +### Health Check +```bash +curl http://localhost:8000/health +``` + +### Analyze Time Series +```bash +curl -X POST http://localhost:8000/api/v1/analyze \ + -H "Content-Type: application/json" \ + -d '{ + "predicted": [1.0, 2.5, 3.2, 4.1, 5.0, 3.8, 2.9], + "target": [1.2, 2.3, 3.5, 3.9, 4.8, 4.0, 3.1] + }' +``` + +### Get Metrics Info +```bash +curl http://localhost:8000/api/v1/metrics/info +``` + +## Response Format + +The `/api/v1/analyze` endpoint returns 28+ metrics: + +```json +{ + "RMSE": 0.234, + "NSC": 0.892, + "Cor": 0.945, + "KGE2009": 0.876, + "Er": [0.2, -0.2, 0.3, ...], + ... +} +``` + +## Testing + +```python +import requests + +response = requests.post( + "http://localhost:8000/api/v1/analyze", + json={ + "predicted": [1.0, 2.0, 3.0, 4.0, 5.0], + "target": [1.1, 2.1, 2.9, 4.2, 4.8] + } +) + +print(response.json()) +``` + +## CORS Configuration + +Currently set to allow all origins (`*`) for development. + +**For production**, update `main.py`: +```python +allow_origins=["https://yourdomain.com"] +``` + +## Next Steps + +- Stage 2: User statistics tracking +- Stage 3: Dockerization +- Stage 4: React frontend diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..04f9492 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# API package marker diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..e69c902 --- /dev/null +++ b/api/database.py @@ -0,0 +1,86 @@ +# --------------------------------------------------------------------------- +# File : api/database.py +# Purpose : Database models and session management +# License : MIT +# --------------------------------------------------------------------------- +from sqlalchemy import create_engine, Column, Integer, Float, String, DateTime, JSON, Text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from datetime import datetime +import os + +# Database configuration +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./ts_analysis.db") + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +# ============================================================================ +# DATABASE MODELS +# ============================================================================ + +class AnalysisRecord(Base): + """Record of each analysis performed""" + __tablename__ = "analysis_records" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime, default=datetime.utcnow, index=True) + session_id = Column(String, index=True, nullable=True) + + # Input metadata + n_points = Column(Integer) + + # Key metrics (for quick queries) + rmse = Column(Float) + nsc = Column(Float) + correlation = Column(Float) + kge2009 = Column(Float) + + # Full metrics as JSON + metrics_json = Column(JSON) + + # Optional user metadata + user_id = Column(String, index=True, nullable=True) + analysis_name = Column(String, nullable=True) + notes = Column(Text, nullable=True) + +class UserSession(Base): + """Track user sessions""" + __tablename__ = "user_sessions" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(String, unique=True, index=True) + user_id = Column(String, index=True, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + last_active = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + analysis_count = Column(Integer, default=0) + +class SystemStats(Base): + """System-wide statistics""" + __tablename__ = "system_stats" + + id = Column(Integer, primary_key=True, index=True) + date = Column(DateTime, default=datetime.utcnow, index=True) + total_analyses = Column(Integer, default=0) + total_sessions = Column(Integer, default=0) + total_users = Column(Integer, default=0) + +# Create tables +Base.metadata.create_all(bind=engine) + +# ============================================================================ +# DATABASE DEPENDENCY +# ============================================================================ + +def get_db(): + """Database session dependency for FastAPI""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..267e827 --- /dev/null +++ b/api/main.py @@ -0,0 +1,509 @@ +# --------------------------------------------------------------------------- +# File : api/main.py +# Purpose : FastAPI backend for TS-ErrorsAnalysis web service +# Author : Generated for TS-ErrorsAnalysis +# Version : 1.0 +# License : MIT +# SPDX-License-Identifier: MIT +# --------------------------------------------------------------------------- +from fastapi import FastAPI, HTTPException, Depends, Header +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +import numpy as np +import sys +from pathlib import Path +import uuid + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) +from src.errors import compute_error_metrics +from .database import get_db +from . import stats as stats_module +from . import timeseries as ts_module + +app = FastAPI( + title="TS-ErrorsAnalysis API", + description="Hydrological time series error analysis and visualization API", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS middleware for React frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure for production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ============================================================================ +# REQUEST/RESPONSE MODELS +# ============================================================================ + +class TimeSeriesInput(BaseModel): + """Input model for time series analysis""" + predicted: List[float] = Field(..., description="Predicted values", min_length=2) + target: List[float] = Field(..., description="Target/observed values", min_length=2) + user_id: Optional[str] = Field(None, description="Optional user ID for tracking") + analysis_name: Optional[str] = Field(None, description="Optional name for this analysis") + notes: Optional[str] = Field(None, description="Optional notes") + + class Config: + json_schema_extra = { + "example": { + "predicted": [1.0, 2.5, 3.2, 4.1, 5.0], + "target": [1.2, 2.3, 3.5, 3.9, 4.8], + "user_id": "user123", + "analysis_name": "River discharge comparison" + } + } + +class ErrorMetricsResponse(BaseModel): + """Response model for error metrics""" + # Basic metrics + RMSE: float + NSC: float + Cor: float + NRMSE: float + MAE: float + + # Statistical properties + StdT: float + StdP: float + MuT: float + MuP: float + + # Persistence metrics + PERS: float + SSE: float + SSEN: float + RMSEN: float + NRMSEN: float + MARE: float + + # Advanced metrics + R2: float + RSR: float + PBIAS: float + sMAPE: float + KGE2009: float + KGE2012: float + d: float + d1: float + + # Error analysis + Er: List[float] + Po: float + Pu: float + +class HealthResponse(BaseModel): + """Health check response""" + status: str + version: str + service: str + +# ============================================================================ +# ENDPOINTS +# ============================================================================ + +@app.get("/", response_model=HealthResponse) +async def root(): + """Root endpoint - health check""" + return { + "status": "healthy", + "version": "1.0.0", + "service": "TS-ErrorsAnalysis API" + } + +@app.get("/health", response_model=HealthResponse) +async def health_check(): + """Health check endpoint for container orchestration""" + return { + "status": "healthy", + "version": "1.0.0", + "service": "TS-ErrorsAnalysis API" + } + +@app.post("/api/v1/analyze", response_model=ErrorMetricsResponse) +async def analyze_time_series( + data: TimeSeriesInput, + db: Session = Depends(get_db), + x_session_id: Optional[str] = Header(None) +): + """ + Analyze time series: compute error metrics for predicted vs target values + + Returns 28+ metrics including: + - RMSE, NSE/NSC, Correlation + - KGE (2009, 2012) + - Index of Agreement (d, d1) + - Persistence metrics + - Error series and proportions + + Automatically tracks usage statistics. + """ + try: + # Convert to numpy arrays + P = np.array(data.predicted) + T = np.array(data.target) + + # Validate lengths match + if len(P) != len(T): + raise HTTPException( + status_code=400, + detail=f"Length mismatch: predicted({len(P)}) vs target({len(T)})" + ) + + # Compute metrics + result = compute_error_metrics(P, T) + + # Convert to response format + response = { + "RMSE": result.RMSE, + "NSC": result.NSC, + "Cor": result.Cor, + "NRMSE": result.NRMSE, + "MAE": result.MAE, + "StdT": result.StdT, + "StdP": result.StdP, + "MuT": result.MuT, + "MuP": result.MuP, + "PERS": result.PERS, + "SSE": result.SSE, + "SSEN": result.SSEN, + "RMSEN": result.RMSEN, + "NRMSEN": result.NRMSEN, + "MARE": result.MARE, + "R2": result.R2, + "RSR": result.RSR, + "PBIAS": result.PBIAS, + "sMAPE": result.sMAPE, + "KGE2009": result.KGE2009, + "KGE2012": result.KGE2012, + "d": result.d, + "d1": result.d1, + "Er": result.Er.tolist(), + "Po": result.Po, + "Pu": result.Pu + } + + # Log analysis to database + session_id = x_session_id or str(uuid.uuid4()) + stats_module.get_or_create_session(db, session_id, data.user_id) + stats_module.log_analysis( + db=db, + metrics=response, + n_points=len(P), + session_id=session_id, + user_id=data.user_id, + analysis_name=data.analysis_name, + notes=data.notes + ) + + return response + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") + +@app.get("/api/v1/metrics/info") +async def metrics_info(): + """ + Get information about all available metrics + """ + return { + "metrics": { + "basic_errors": { + "RMSE": "Root Mean Squared Error", + "MAE": "Mean Absolute Error", + "SSE": "Sum of Squared Errors", + "NRMSE": "Normalized RMSE (% of std(T))" + }, + "model_skill": { + "NSC": "Nash-Sutcliffe Efficiency (NSE)", + "Cor": "Pearson correlation coefficient", + "R2": "Coefficient of determination", + "RSR": "RMSE-to-StdDev ratio" + }, + "bias_metrics": { + "PBIAS": "Percent Bias", + "sMAPE": "Symmetric Mean Absolute Percentage Error", + "MARE": "Mean Absolute Relative Error" + }, + "hydrology_specific": { + "KGE2009": "Kling-Gupta Efficiency (2009)", + "KGE2012": "Modified KGE (2012, CV ratio)", + "d": "Index of Agreement (Willmott 1981)", + "d1": "Modified Index of Agreement (absolute)" + }, + "persistence": { + "PERS": "Coefficient of persistence", + "RMSEN": "RMSE of naive lag-1 forecast", + "NRMSEN": "Normalized RMSEN", + "SSEN": "SSE of persistence baseline" + }, + "statistics": { + "MuT": "Mean of target values", + "MuP": "Mean of predicted values", + "StdT": "Std dev of target (ddof=0)", + "StdP": "Std dev of predicted (ddof=0)" + }, + "error_analysis": { + "Er": "Error series (T - P)", + "Po": "Proportion overestimations (P > T)", + "Pu": "Proportion underestimations (P < T)" + } + }, + "conventions": { + "error_sign": "Er = T - P (positive = underestimate)", + "std_method": "Population std (ddof=0) to match MATLAB", + "nan_handling": "Pairwise deletion, requires β‰₯2 valid pairs" + } + } + +@app.get("/api/v1/stats/user/{user_id}") +async def get_user_statistics(user_id: str, db: Session = Depends(get_db)): + """Get statistics for a specific user""" + return stats_module.get_user_stats(db, user_id) + +@app.get("/api/v1/stats/system") +async def get_system_statistics(db: Session = Depends(get_db)): + """Get overall system statistics""" + return stats_module.get_system_stats(db) + +@app.get("/api/v1/history") +async def get_analysis_history( + user_id: Optional[str] = None, + session_id: Optional[str] = None, + limit: int = 50, + db: Session = Depends(get_db) +): + """ + Get analysis history + + Query parameters: + - user_id: Filter by user ID + - session_id: Filter by session ID + - limit: Max number of records (default 50) + """ + return stats_module.get_analysis_history(db, user_id, session_id, limit) + +# ============================================================================ +# TIME SERIES PROCESSING ENDPOINTS +# ============================================================================ + +class TimeSeriesData(BaseModel): + """Time series data input""" + values: List[float] = Field(..., description="Time series values") + indices: Optional[List[float]] = Field(None, description="Optional time indices") + +class InterpolateRequest(BaseModel): + """Spline interpolation request""" + values: List[float] + indices: Optional[List[float]] = None + kind: str = Field('cubic', description="Interpolation kind: linear, quadratic, cubic") + num_points: Optional[int] = Field(None, description="Number of output points") + +class SmoothRequest(BaseModel): + """Data smoothing request""" + values: List[float] + method: str = Field('moving_average', description="Smoothing method") + window_size: int = Field(5, description="Window size for smoothing") + polyorder: Optional[int] = Field(2, description="Polynomial order for Savitzky-Golay") + alpha: Optional[float] = Field(0.3, description="Alpha for exponential smoothing") + +class FillMissingRequest(BaseModel): + """Fill missing data request""" + values: List[float] + method: str = Field('linear', description="Fill method: linear, forward, backward, mean, median") + limit: Optional[int] = Field(None, description="Max consecutive NaNs to fill") + +class DecomposeRequest(BaseModel): + """Trend decomposition request""" + values: List[float] + period: int = Field(12, description="Seasonal period") + model: str = Field('additive', description="Model type: additive or multiplicative") + +class OutlierRequest(BaseModel): + """Outlier detection request""" + values: List[float] + method: str = Field('zscore', description="Detection method: zscore or iqr") + threshold: float = Field(3.0, description="Threshold for detection") + +class ResampleRequest(BaseModel): + """Resampling request""" + values: List[float] + indices: Optional[List[float]] = None + target_points: int = Field(..., description="Target number of points") + method: str = Field('linear', description="Interpolation method") + +@app.post("/api/v1/timeseries/interpolate") +async def interpolate_timeseries(request: InterpolateRequest): + """ + Perform spline interpolation on time series data + + Returns interpolated x and y values + """ + try: + values = np.array(request.values) + indices = np.array(request.indices) if request.indices else np.arange(len(values)) + + x_new, y_new = ts_module.spline_interpolate( + indices, values, + kind=request.kind, + num_points=request.num_points + ) + + return { + "indices": x_new.tolist(), + "values": y_new.tolist(), + "method": request.kind, + "original_points": len(values), + "interpolated_points": len(y_new) + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/v1/timeseries/smooth") +async def smooth_timeseries(request: SmoothRequest): + """ + Smooth time series data using various methods + + Methods: moving_average, savitzky_golay, exponential + """ + try: + values = np.array(request.values) + + kwargs = {} + if request.method == 'savitzky_golay': + kwargs['polyorder'] = request.polyorder + elif request.method == 'exponential': + kwargs['alpha'] = request.alpha + + smoothed = ts_module.smooth_data( + values, + method=request.method, + window_size=request.window_size, + **kwargs + ) + + return { + "original": values.tolist(), + "smoothed": smoothed.tolist(), + "method": request.method, + "window_size": request.window_size + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/v1/timeseries/fill-missing") +async def fill_missing_data(request: FillMissingRequest): + """ + Fill missing (NaN) values in time series + + Methods: linear, forward, backward, mean, median + """ + try: + values = np.array(request.values, dtype=float) + + filled, mask = ts_module.fill_missing_data( + values, + method=request.method, + limit=request.limit + ) + + return { + "original": values.tolist(), + "filled": filled.tolist(), + "filled_indices": np.where(mask)[0].tolist(), + "num_filled": int(np.sum(mask)), + "method": request.method + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/v1/timeseries/decompose") +async def decompose_trend(request: DecomposeRequest): + """ + Decompose time series into trend, seasonal, and residual components + """ + try: + values = np.array(request.values) + + components = ts_module.decompose_trend( + values, + period=request.period, + model=request.model + ) + + return { + "original": components['original'].tolist(), + "trend": components['trend'].tolist(), + "seasonal": components['seasonal'].tolist(), + "residual": components['residual'].tolist(), + "period": request.period, + "model": request.model + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/v1/timeseries/detect-outliers") +async def detect_outliers(request: OutlierRequest): + """ + Detect outliers in time series data + + Methods: zscore, iqr + """ + try: + values = np.array(request.values) + + outliers = ts_module.detect_outliers( + values, + method=request.method, + threshold=request.threshold + ) + + return { + "values": values.tolist(), + "is_outlier": outliers.tolist(), + "outlier_indices": np.where(outliers)[0].tolist(), + "num_outliers": int(np.sum(outliers)), + "method": request.method, + "threshold": request.threshold + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@app.post("/api/v1/timeseries/resample") +async def resample_timeseries(request: ResampleRequest): + """ + Resample time series to a different number of points + """ + try: + values = np.array(request.values) + indices = np.array(request.indices) if request.indices else np.arange(len(values)) + + x_new, y_new = ts_module.resample_timeseries( + indices, values, + target_points=request.target_points, + method=request.method + ) + + return { + "original_points": len(values), + "resampled_points": len(y_new), + "indices": x_new.tolist(), + "values": y_new.tolist(), + "method": request.method + } + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..20e93f1 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.6 +numpy==2.3.2 +python-multipart==0.0.20 +sqlalchemy==2.0.36 +python-dateutil==2.9.0.post0 +scipy==1.15.1 diff --git a/api/stats.py b/api/stats.py new file mode 100644 index 0000000..8f20f6f --- /dev/null +++ b/api/stats.py @@ -0,0 +1,202 @@ +# --------------------------------------------------------------------------- +# File : api/stats.py +# Purpose : Statistics calculation and retrieval functions +# License : MIT +# --------------------------------------------------------------------------- +from sqlalchemy.orm import Session +from sqlalchemy import func, desc +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional +from .database import AnalysisRecord, UserSession, SystemStats + +def log_analysis( + db: Session, + metrics: Dict[str, Any], + n_points: int, + session_id: Optional[str] = None, + user_id: Optional[str] = None, + analysis_name: Optional[str] = None, + notes: Optional[str] = None +) -> AnalysisRecord: + """Log an analysis to the database""" + + record = AnalysisRecord( + session_id=session_id, + user_id=user_id, + n_points=n_points, + rmse=metrics.get("RMSE"), + nsc=metrics.get("NSC"), + correlation=metrics.get("Cor"), + kge2009=metrics.get("KGE2009"), + metrics_json=metrics, + analysis_name=analysis_name, + notes=notes + ) + + db.add(record) + db.commit() + db.refresh(record) + + # Update session stats + if session_id: + session = db.query(UserSession).filter(UserSession.session_id == session_id).first() + if session: + session.analysis_count += 1 + session.last_active = datetime.utcnow() + db.commit() + + return record + +def get_or_create_session(db: Session, session_id: str, user_id: Optional[str] = None) -> UserSession: + """Get existing session or create new one""" + session = db.query(UserSession).filter(UserSession.session_id == session_id).first() + + if not session: + session = UserSession( + session_id=session_id, + user_id=user_id, + analysis_count=0 + ) + db.add(session) + db.commit() + db.refresh(session) + else: + session.last_active = datetime.utcnow() + if user_id and not session.user_id: + session.user_id = user_id + db.commit() + + return session + +def get_user_stats(db: Session, user_id: str) -> Dict[str, Any]: + """Get statistics for a specific user""" + + # Total analyses + total_analyses = db.query(func.count(AnalysisRecord.id))\ + .filter(AnalysisRecord.user_id == user_id)\ + .scalar() or 0 + + # Recent analyses (last 30 days) + thirty_days_ago = datetime.utcnow() - timedelta(days=30) + recent_analyses = db.query(func.count(AnalysisRecord.id))\ + .filter(AnalysisRecord.user_id == user_id)\ + .filter(AnalysisRecord.timestamp >= thirty_days_ago)\ + .scalar() or 0 + + # Average metrics + avg_metrics = db.query( + func.avg(AnalysisRecord.rmse).label("avg_rmse"), + func.avg(AnalysisRecord.nsc).label("avg_nsc"), + func.avg(AnalysisRecord.correlation).label("avg_cor"), + func.avg(AnalysisRecord.kge2009).label("avg_kge") + ).filter(AnalysisRecord.user_id == user_id).first() + + # Recent analyses + recent = db.query(AnalysisRecord)\ + .filter(AnalysisRecord.user_id == user_id)\ + .order_by(desc(AnalysisRecord.timestamp))\ + .limit(10)\ + .all() + + return { + "user_id": user_id, + "total_analyses": total_analyses, + "recent_analyses_30d": recent_analyses, + "average_metrics": { + "rmse": float(avg_metrics.avg_rmse) if avg_metrics.avg_rmse else None, + "nsc": float(avg_metrics.avg_nsc) if avg_metrics.avg_nsc else None, + "correlation": float(avg_metrics.avg_cor) if avg_metrics.avg_cor else None, + "kge2009": float(avg_metrics.avg_kge) if avg_metrics.avg_kge else None + }, + "recent_analyses": [ + { + "id": r.id, + "timestamp": r.timestamp.isoformat(), + "n_points": r.n_points, + "rmse": r.rmse, + "nsc": r.nsc, + "name": r.analysis_name + } for r in recent + ] + } + +def get_system_stats(db: Session) -> Dict[str, Any]: + """Get overall system statistics""" + + # Total counts + total_analyses = db.query(func.count(AnalysisRecord.id)).scalar() or 0 + total_sessions = db.query(func.count(UserSession.id)).scalar() or 0 + unique_users = db.query(func.count(func.distinct(AnalysisRecord.user_id)))\ + .filter(AnalysisRecord.user_id.isnot(None))\ + .scalar() or 0 + + # Last 24 hours + yesterday = datetime.utcnow() - timedelta(days=1) + analyses_24h = db.query(func.count(AnalysisRecord.id))\ + .filter(AnalysisRecord.timestamp >= yesterday)\ + .scalar() or 0 + + # Average metrics across all analyses + avg_metrics = db.query( + func.avg(AnalysisRecord.rmse).label("avg_rmse"), + func.avg(AnalysisRecord.nsc).label("avg_nsc"), + func.avg(AnalysisRecord.correlation).label("avg_cor"), + func.avg(AnalysisRecord.kge2009).label("avg_kge") + ).first() + + # Most active users + top_users = db.query( + AnalysisRecord.user_id, + func.count(AnalysisRecord.id).label("count") + ).filter(AnalysisRecord.user_id.isnot(None))\ + .group_by(AnalysisRecord.user_id)\ + .order_by(desc("count"))\ + .limit(5)\ + .all() + + return { + "total_analyses": total_analyses, + "total_sessions": total_sessions, + "unique_users": unique_users, + "analyses_last_24h": analyses_24h, + "average_metrics": { + "rmse": float(avg_metrics.avg_rmse) if avg_metrics.avg_rmse else None, + "nsc": float(avg_metrics.avg_nsc) if avg_metrics.avg_nsc else None, + "correlation": float(avg_metrics.avg_cor) if avg_metrics.avg_cor else None, + "kge2009": float(avg_metrics.avg_kge) if avg_metrics.avg_kge else None + }, + "top_users": [ + {"user_id": u[0], "analysis_count": u[1]} for u in top_users + ] + } + +def get_analysis_history( + db: Session, + user_id: Optional[str] = None, + session_id: Optional[str] = None, + limit: int = 50 +) -> List[Dict[str, Any]]: + """Get analysis history""" + + query = db.query(AnalysisRecord) + + if user_id: + query = query.filter(AnalysisRecord.user_id == user_id) + if session_id: + query = query.filter(AnalysisRecord.session_id == session_id) + + records = query.order_by(desc(AnalysisRecord.timestamp)).limit(limit).all() + + return [ + { + "id": r.id, + "timestamp": r.timestamp.isoformat(), + "n_points": r.n_points, + "rmse": r.rmse, + "nsc": r.nsc, + "correlation": r.correlation, + "kge2009": r.kge2009, + "name": r.analysis_name, + "metrics": r.metrics_json + } for r in records + ] diff --git a/api/timeseries.py b/api/timeseries.py new file mode 100644 index 0000000..1fd845a --- /dev/null +++ b/api/timeseries.py @@ -0,0 +1,329 @@ +# --------------------------------------------------------------------------- +# File : api/timeseries.py +# Purpose : Advanced time series processing functions +# License : MIT +# --------------------------------------------------------------------------- +import numpy as np +from scipy import interpolate, signal +from typing import Tuple, Optional, Dict, Any +import warnings + +def spline_interpolate( + x: np.ndarray, + y: np.ndarray, + kind: str = 'cubic', + num_points: Optional[int] = None, + fill_value: str = 'extrapolate' +) -> Tuple[np.ndarray, np.ndarray]: + """ + Perform spline interpolation on time series data + + Args: + x: x-values (time indices) + y: y-values (observations) + kind: 'linear', 'quadratic', 'cubic' + num_points: Number of interpolated points (default: 2x original) + fill_value: How to handle extrapolation + + Returns: + x_new, y_new: Interpolated arrays + """ + # Remove NaN values + mask = np.isfinite(y) + x_clean, y_clean = x[mask], y[mask] + + if len(x_clean) < 2: + raise ValueError("Need at least 2 valid points for interpolation") + + # Create interpolator + if kind in ['linear', 'quadratic', 'cubic']: + f = interpolate.interp1d(x_clean, y_clean, kind=kind, fill_value=fill_value) + else: + raise ValueError(f"Unknown interpolation kind: {kind}") + + # Generate new x values + if num_points is None: + num_points = len(x) * 2 + + x_new = np.linspace(x_clean[0], x_clean[-1], num_points) + y_new = f(x_new) + + return x_new, y_new + +def smooth_data( + y: np.ndarray, + method: str = 'moving_average', + window_size: int = 5, + **kwargs +) -> np.ndarray: + """ + Smooth time series data using various methods + + Args: + y: Input data + method: 'moving_average', 'savitzky_golay', 'exponential' + window_size: Window size for smoothing + **kwargs: Additional method-specific parameters + + Returns: + Smoothed data array + """ + if method == 'moving_average': + return moving_average(y, window_size) + + elif method == 'savitzky_golay': + polyorder = kwargs.get('polyorder', 2) + if window_size % 2 == 0: + window_size += 1 # Must be odd + return signal.savgol_filter(y, window_size, polyorder) + + elif method == 'exponential': + alpha = kwargs.get('alpha', 0.3) + return exponential_smoothing(y, alpha) + + else: + raise ValueError(f"Unknown smoothing method: {method}") + +def moving_average(y: np.ndarray, window_size: int) -> np.ndarray: + """Simple moving average""" + if window_size < 1: + raise ValueError("Window size must be >= 1") + + cumsum = np.cumsum(np.insert(y, 0, 0)) + result = (cumsum[window_size:] - cumsum[:-window_size]) / window_size + + # Pad to maintain original length + pad_size = len(y) - len(result) + if pad_size > 0: + result = np.concatenate([np.full(pad_size, result[0]), result]) + + return result + +def exponential_smoothing(y: np.ndarray, alpha: float = 0.3) -> np.ndarray: + """Exponential smoothing (simple exponential moving average)""" + if not 0 < alpha <= 1: + raise ValueError("Alpha must be in (0, 1]") + + result = np.zeros_like(y) + result[0] = y[0] + + for i in range(1, len(y)): + result[i] = alpha * y[i] + (1 - alpha) * result[i - 1] + + return result + +def fill_missing_data( + y: np.ndarray, + method: str = 'linear', + limit: Optional[int] = None +) -> Tuple[np.ndarray, np.ndarray]: + """ + Fill missing (NaN) values in time series + + Args: + y: Input data with potential NaN values + method: 'linear', 'forward', 'backward', 'mean', 'median' + limit: Maximum number of consecutive NaNs to fill + + Returns: + filled_data, mask (True where data was filled) + """ + y_filled = y.copy() + mask = np.isnan(y) + + if not np.any(mask): + return y_filled, np.zeros_like(y, dtype=bool) + + if method == 'linear': + # Linear interpolation + indices = np.arange(len(y)) + valid = ~mask + if np.sum(valid) >= 2: + y_filled[mask] = np.interp(indices[mask], indices[valid], y[valid]) + + elif method == 'forward': + # Forward fill + y_filled = forward_fill(y, limit) + + elif method == 'backward': + # Backward fill + y_filled = backward_fill(y, limit) + + elif method == 'mean': + # Fill with mean + mean_val = np.nanmean(y) + y_filled[mask] = mean_val + + elif method == 'median': + # Fill with median + median_val = np.nanmedian(y) + y_filled[mask] = median_val + + else: + raise ValueError(f"Unknown fill method: {method}") + + return y_filled, mask + +def forward_fill(y: np.ndarray, limit: Optional[int] = None) -> np.ndarray: + """Forward fill NaN values""" + result = y.copy() + mask = np.isnan(result) + + last_valid = None + consecutive_nans = 0 + + for i in range(len(result)): + if not mask[i]: + last_valid = result[i] + consecutive_nans = 0 + elif last_valid is not None: + consecutive_nans += 1 + if limit is None or consecutive_nans <= limit: + result[i] = last_valid + + return result + +def backward_fill(y: np.ndarray, limit: Optional[int] = None) -> np.ndarray: + """Backward fill NaN values""" + result = y.copy() + mask = np.isnan(result) + + next_valid = None + consecutive_nans = 0 + + for i in range(len(result) - 1, -1, -1): + if not mask[i]: + next_valid = result[i] + consecutive_nans = 0 + elif next_valid is not None: + consecutive_nans += 1 + if limit is None or consecutive_nans <= limit: + result[i] = next_valid + + return result + +def decompose_trend( + y: np.ndarray, + period: int = 12, + model: str = 'additive' +) -> Dict[str, np.ndarray]: + """ + Decompose time series into trend, seasonal, and residual components + + Args: + y: Time series data + period: Seasonal period + model: 'additive' or 'multiplicative' + + Returns: + Dictionary with 'trend', 'seasonal', 'residual' components + """ + from scipy.ndimage import uniform_filter1d + + n = len(y) + + # Compute trend using moving average + if period % 2 == 0: + # Even period: use centered moving average + trend = uniform_filter1d(y, period, mode='nearest') + else: + trend = uniform_filter1d(y, period, mode='nearest') + + # Detrend + if model == 'additive': + detrended = y - trend + elif model == 'multiplicative': + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + detrended = y / (trend + 1e-10) + else: + raise ValueError(f"Unknown model: {model}") + + # Compute seasonal component + seasonal = np.zeros(n) + for i in range(period): + indices = np.arange(i, n, period) + seasonal[indices] = np.nanmean(detrended[indices]) + + # Compute residual + if model == 'additive': + residual = y - trend - seasonal + else: + residual = y / ((trend + 1e-10) * (seasonal + 1e-10)) + + return { + 'trend': trend, + 'seasonal': seasonal, + 'residual': residual, + 'original': y + } + +def detect_outliers( + y: np.ndarray, + method: str = 'zscore', + threshold: float = 3.0 +) -> np.ndarray: + """ + Detect outliers in time series + + Args: + y: Input data + method: 'zscore' or 'iqr' + threshold: Z-score threshold (or IQR multiplier) + + Returns: + Boolean array (True for outliers) + """ + if method == 'zscore': + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + z_scores = np.abs((y - np.nanmean(y)) / (np.nanstd(y) + 1e-10)) + return z_scores > threshold + + elif method == 'iqr': + q1 = np.nanpercentile(y, 25) + q3 = np.nanpercentile(y, 75) + iqr = q3 - q1 + lower_bound = q1 - threshold * iqr + upper_bound = q3 + threshold * iqr + return (y < lower_bound) | (y > upper_bound) + + else: + raise ValueError(f"Unknown method: {method}") + +def resample_timeseries( + x: np.ndarray, + y: np.ndarray, + target_points: int, + method: str = 'linear' +) -> Tuple[np.ndarray, np.ndarray]: + """ + Resample time series to a different number of points + + Args: + x: Original x values + y: Original y values + target_points: Desired number of points + method: Interpolation method + + Returns: + x_new, y_new: Resampled arrays + """ + # Remove NaN + mask = np.isfinite(y) + x_clean, y_clean = x[mask], y[mask] + + if len(x_clean) < 2: + raise ValueError("Need at least 2 valid points") + + # Create new x array + x_new = np.linspace(x_clean[0], x_clean[-1], target_points) + + # Interpolate + if method == 'linear': + y_new = np.interp(x_new, x_clean, y_clean) + else: + f = interpolate.interp1d(x_clean, y_clean, kind=method, fill_value='extrapolate') + y_new = f(x_new) + + return x_new, y_new diff --git a/deploy-railway.sh b/deploy-railway.sh new file mode 100755 index 0000000..62bd6ca --- /dev/null +++ b/deploy-railway.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Quick deployment script for Railway + +echo "πŸš€ Deploying TS-ErrorsAnalysis to Railway..." + +# Check if Railway CLI is installed +if ! command -v railway &> /dev/null; then + echo "πŸ“¦ Installing Railway CLI..." + npm install -g @railway/cli +fi + +# Login to Railway +echo "πŸ” Logging in to Railway..." +railway login + +# Initialize project +echo "🎯 Creating Railway project..." +railway init + +# Link to GitHub repo (optional) +echo "πŸ”— Linking to GitHub..." +echo "Please link your GitHub repository in the Railway dashboard" + +# Deploy backend +echo "πŸš‚ Deploying backend..." +railway up + +# Get backend URL +BACKEND_URL=$(railway domain) +echo "βœ… Backend deployed to: $BACKEND_URL" + +# Deploy frontend separately +echo "πŸ“± To deploy frontend:" +echo "1. Go to https://vercel.com/new" +echo "2. Import your GitHub repository" +echo "3. Set Root Directory to: frontend" +echo "4. Set Environment Variable: VITE_API_URL=$BACKEND_URL" +echo "5. Deploy!" + +echo "" +echo "πŸŽ‰ Deployment initiated!" +echo "πŸ“Š Monitor deployment: https://railway.app/dashboard" diff --git a/deploy-render.sh b/deploy-render.sh new file mode 100755 index 0000000..57e03f4 --- /dev/null +++ b/deploy-render.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Quick deployment script for Render + +echo "πŸš€ Deploying TS-ErrorsAnalysis to Render..." + +echo "πŸ“‹ Step 1: Create render.yaml (already done βœ“)" + +echo "" +echo "πŸ“‹ Step 2: Deploy to Render" +echo " 1. Go to: https://dashboard.render.com/select-repo" +echo " 2. Connect your GitHub: corzogac/TS-ErrorsAnalysis" +echo " 3. Select branch: claude/add-claude-documentation-d60DI" +echo " 4. Render will detect render.yaml and auto-configure!" +echo " 5. Click 'Apply'" + +echo "" +echo "πŸ“‹ Step 3: Deploy Frontend to Vercel" +echo " 1. Go to: https://vercel.com/new" +echo " 2. Import: corzogac/TS-ErrorsAnalysis" +echo " 3. Root Directory: frontend" +echo " 4. Environment Variables:" +echo " VITE_API_URL=" +echo " 5. Deploy!" + +echo "" +echo "🎯 Alternative: One-Command Deploy" +echo " Click: https://render.com/deploy?repo=https://github.com/corzogac/TS-ErrorsAnalysis" + +echo "" +echo "πŸ“š Full instructions: See DEPLOYMENT.md" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..dc1fd33 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: ts-errors-api-prod + ports: + - "8000:8000" + environment: + - DATABASE_URL=${DATABASE_URL:-sqlite:///./data/ts_analysis.db} + - PYTHONUNBUFFERED=1 + volumes: + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c9712cb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +version: '3.8' + +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: ts-errors-api + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite:///./ts_analysis.db + - PYTHONUNBUFFERED=1 + volumes: + # Mount for development - hot reload + - ./src:/app/src + - ./api:/app/api + # Persist database + - ./data:/app/data + command: uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + + # Frontend will be added in Stage 4 + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # container_name: ts-errors-frontend + # ports: + # - "3000:3000" + # depends_on: + # - api + # environment: + # - REACT_APP_API_URL=http://localhost:8000 + +volumes: + data: + driver: local + +networks: + default: + name: ts-errors-network diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..a3cdc84 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# API Configuration +VITE_API_URL=http://localhost:8000 + +# Development +VITE_DEV_MODE=true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f3e3fac --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM node:20-alpine as build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the app +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..bb36c78 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,147 @@ +# TS-ErrorsAnalysis Frontend + +Modern React dashboard for hydrological time series error analysis. + +## Tech Stack + +- **React 18** with Vite (fast dev server & builds) +- **Tailwind CSS** for styling +- **Recharts** for data visualization +- **React Router** for navigation +- **Axios** for API calls + +## Quick Start + +### Development Mode + +```bash +cd frontend + +# Install dependencies +npm install + +# Start dev server +npm run dev +``` + +The app will be available at http://localhost:3000 + +### Production Build + +```bash +npm run build +npm run preview +``` + +## Project Structure + +``` +frontend/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ pages/ # Page components (Dashboard, Analyze, etc.) +β”‚ β”œβ”€β”€ services/ # API service layer +β”‚ β”œβ”€β”€ utils/ # Utility functions +β”‚ β”œβ”€β”€ types/ # TypeScript types (future) +β”‚ β”œβ”€β”€ App.jsx # Main app component +β”‚ β”œβ”€β”€ main.jsx # Entry point +β”‚ └── index.css # Global styles + Tailwind +β”œβ”€β”€ public/ # Static assets +β”œβ”€β”€ index.html # HTML template +β”œβ”€β”€ package.json # Dependencies +β”œβ”€β”€ vite.config.js # Vite configuration +└── tailwind.config.js # Tailwind configuration +``` + +## Features + +### Pages + +1. **Dashboard** (`/`) + - System statistics overview + - Average metrics + - Feature highlights + - Quick access to analysis + +2. **Analyze** (`/analyze`) + - Input predicted & target time series + - Real-time analysis + - 28+ error metrics display + - Error visualization chart + - Export results as JSON + +3. **History** (`/history`) + - View past analyses + - Filter by user ID + - Expandable metric details + - Pagination support + +4. **Stats** (`/stats`) + - System-wide statistics + - User-specific statistics + - Top users chart + - Average metrics over time + +### API Integration + +The frontend connects to the FastAPI backend at `http://localhost:8000`: + +- `POST /api/v1/analyze` - Run analysis +- `GET /api/v1/stats/system` - System stats +- `GET /api/v1/stats/user/{id}` - User stats +- `GET /api/v1/history` - Analysis history + +Session ID is automatically generated and persisted in localStorage. + +## Environment Variables + +Create `.env` file in frontend directory: + +```env +VITE_API_URL=http://localhost:8000 +``` + +## Customization + +### Styling + +Edit `tailwind.config.js` to customize colors, fonts, etc: + +```js +theme: { + extend: { + colors: { + primary: { /* your colors */ } + } + } +} +``` + +### API Endpoint + +Update `src/services/api.js`: + +```js +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://your-api-url' +``` + +## Docker Deployment + +The frontend will be added to `docker-compose.yml` in Stage 4: + +```bash +# Build +docker build -t ts-errors-frontend . + +# Run +docker run -p 3000:3000 ts-errors-frontend +``` + +## Next Steps + +- Stage 5: Add advanced time series tools (spline, interpolation) +- Stage 6: Implement spatial analysis +- Stage 7: Add 2D/3D visualization +- Add TypeScript for type safety +- Add unit tests with Vitest +- Add E2E tests with Playwright diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a78f4e8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + TS-ErrorsAnalysis - Hydrological Time Series Analysis + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e01bdc8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml; + + # React Router support + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy (optional, if backend on same domain) + location /api { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Health check + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c593d5e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "ts-errors-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^2.15.0", + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "clsx": "^2.1.1" + }, + "devDependencies": { + "@types/react": "^18.3.17", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7ce4625 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,71 @@ +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom' +import { BarChart3, Home, History, TrendingUp, Wand2 } from 'lucide-react' +import Dashboard from './pages/Dashboard' +import Analyze from './pages/Analyze' +import HistoryPage from './pages/HistoryPage' +import Stats from './pages/Stats' +import Tools from './pages/Tools' + +function App() { + return ( + +
+ {/* Header */} +
+
+
+
+ +
+

TS-ErrorsAnalysis

+

Hydrological Time Series Analysis

+
+
+ +
+
+
+ + {/* Main Content */} +
+ + } /> + } /> + } /> + } /> + } /> + +
+ + {/* Footer */} +
+
+

+ TS-ErrorsAnalysis v1.0 | MIT License | IHE Delft +

+
+
+
+
+ ) +} + +function NavLink({ to, icon, children }) { + return ( + + {icon} + {children} + + ) +} + +export default App diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a178a62 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,35 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-50 text-gray-900; + } +} + +@layer components { + .card { + @apply bg-white rounded-lg shadow-md p-6; + } + + .btn { + @apply px-4 py-2 rounded-md font-medium transition-colors; + } + + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700; + } + + .btn-secondary { + @apply bg-gray-200 text-gray-700 hover:bg-gray-300; + } + + .input { + @apply w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500; + } + + .label { + @apply block text-sm font-medium text-gray-700 mb-1; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/pages/Analyze.jsx b/frontend/src/pages/Analyze.jsx new file mode 100644 index 0000000..79de999 --- /dev/null +++ b/frontend/src/pages/Analyze.jsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { Upload, Download } from 'lucide-react' +import { analysisApi } from '../services/api' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' + +export default function Analyze() { + const [predicted, setPredicted] = useState('') + const [target, setTarget] = useState('') + const [userId, setUserId] = useState('') + const [analysisName, setAnalysisName] = useState('') + const [results, setResults] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleAnalyze = async (e) => { + e.preventDefault() + setLoading(true) + setError(null) + + try { + // Parse input arrays + const predictedArray = predicted.split(/[,\s]+/).map(Number).filter(x => !isNaN(x)) + const targetArray = target.split(/[,\s]+/).map(Number).filter(x => !isNaN(x)) + + if (predictedArray.length < 2 || targetArray.length < 2) { + throw new Error('Please provide at least 2 values in each array') + } + + const data = { + predicted: predictedArray, + target: targetArray, + user_id: userId || undefined, + analysis_name: analysisName || undefined, + } + + const result = await analysisApi.analyze(data) + setResults(result) + } catch (err) { + setError(err.response?.data?.detail || err.message || 'Analysis failed') + } finally { + setLoading(false) + } + } + + const loadExample = () => { + setPredicted('1.0, 2.5, 3.2, 4.1, 5.0, 3.8, 2.9, 4.5, 5.2, 3.7') + setTarget('1.2, 2.3, 3.5, 3.9, 4.8, 4.0, 3.1, 4.3, 5.0, 3.9') + setAnalysisName('Example Analysis') + } + + const downloadResults = () => { + if (!results) return + const json = JSON.stringify(results, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `analysis-${Date.now()}.json` + a.click() + } + + return ( +
+

Analyze Time Series

+ +
+ {/* Input Form */} +
+

Input Data

+
+
+ +