From 112c25c26b060fdba045484cddce227b317dd00f Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 23 May 2026 17:22:31 +0530 Subject: [PATCH] Add CUF profile viewer with upload capability --- packages/api/src/remodel_api/main.py | 3 +- .../api/src/remodel_api/routers/profiles.py | 132 +++++ packages/web/app/page.tsx | 2 + packages/web/components/ProfileViewer.tsx | 515 ++++++++++++++++++ packages/web/lib/api.ts | 52 ++ 5 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/remodel_api/routers/profiles.py create mode 100644 packages/web/components/ProfileViewer.tsx diff --git a/packages/api/src/remodel_api/main.py b/packages/api/src/remodel_api/main.py index 3a06a03..f6f41c8 100644 --- a/packages/api/src/remodel_api/main.py +++ b/packages/api/src/remodel_api/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from remodel_api import __version__ from remodel_api.db.session import init_db -from remodel_api.routers import scenarios, templates +from remodel_api.routers import scenarios, templates, profiles @asynccontextmanager @@ -32,6 +32,7 @@ app.add_middleware( app.include_router(scenarios.router, prefix="/api") app.include_router(templates.router, prefix="/api") +app.include_router(profiles.router, prefix="/api") @app.get("/healthz", tags=["ops"]) diff --git a/packages/api/src/remodel_api/routers/profiles.py b/packages/api/src/remodel_api/routers/profiles.py new file mode 100644 index 0000000..2163229 --- /dev/null +++ b/packages/api/src/remodel_api/routers/profiles.py @@ -0,0 +1,132 @@ +"""Profiles router: serve bundled Solar/Wind CUF profiles.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Query +from fastapi.responses import Response + +router = APIRouter() + + +@router.get("/profiles/solar/{location_id}") +async def get_solar_profile( + location_id: str, + as_csv: bool = Query(False, description="Return raw CSV data"), +) -> Response: + """Return solar irradiance profile for a location (8760 values). + + Args: + location_id: One of 'GJ' (Gujarat), 'KA' (Karnataka), 'RJ' (Rajasthan). + as_csv: If True, return raw CSV; otherwise return JSON array of values. + """ + from remodel_engine.catalog.loader import load_solar_profile + + try: + data = load_solar_profile(location_id) + except ValueError as e: + return Response(content=str(e), status_code=404) + + if as_csv: + lines = ["hour,irradiance_norm"] + for i, v in enumerate(data): + lines.append(f"{i},{v:.6f}") + return Response( + content="\n".join(lines), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=solar_{location_id}_8760.csv"}, + ) + + return {"location_id": location_id, "values": data.tolist(), "count": len(data)} + + +@router.get("/profiles/wind/{location_id}") +async def get_wind_profile( + location_id: str, + as_csv: bool = Query(False, description="Return raw CSV data"), +) -> Response: + """Return wind speed profile for a location (8760 values in m/s). + + Args: + location_id: One of 'GJ' (Gujarat), 'KA' (Karnataka), 'RJ' (Rajasthan). + as_csv: If True, return raw CSV; otherwise return JSON array of values. + """ + from remodel_engine.catalog.loader import load_wind_profile + + try: + data = load_wind_profile(location_id) + except ValueError as e: + return Response(content=str(e), status_code=404) + + if as_csv: + lines = ["hour,wind_speed_ms"] + for i, v in enumerate(data): + lines.append(f"{i},{v:.4f}") + return Response( + content="\n".join(lines), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=wind_{location_id}_8760.csv"}, + ) + + return {"location_id": location_id, "values": data.tolist(), "count": len(data)} + + +@router.get("/profiles/available") +async def get_available_profiles() -> dict: + """Return list of available profiles.""" + return { + "solar": [ + {"id": "GJ", "name": "Gujarat", "path": "/api/profiles/solar/GJ"}, + {"id": "KA", "name": "Karnataka", "path": "/api/profiles/solar/KA"}, + {"id": "RJ", "name": "Rajasthan", "path": "/api/profiles/solar/RJ"}, + ], + "wind": [ + {"id": "GJ", "name": "Gujarat", "path": "/api/profiles/wind/GJ"}, + {"id": "KA", "name": "Karnataka", "path": "/api/profiles/wind/KA"}, + {"id": "RJ", "name": "Rajasthan", "path": "/api/profiles/wind/RJ"}, + ], + } + + +@router.get("/profiles/stats/{kind}/{location_id}") +async def get_profile_stats( + kind: str, + location_id: str, +) -> dict: + """Return statistics for a profile. + + Args: + kind: 'solar' or 'wind' + location_id: Location code (GJ, KA, RJ) + """ + import numpy as np + from remodel_engine.catalog.loader import load_solar_profile, load_wind_profile + + try: + if kind == "solar": + data = load_solar_profile(location_id) + elif kind == "wind": + data = load_wind_profile(location_id) + else: + return {"error": "kind must be 'solar' or 'wind'"} + except ValueError as e: + return {"error": str(e)} + + sorted_data = np.sort(data) + return { + "kind": kind, + "location_id": location_id, + "count": len(data), + "avg": float(np.mean(data)), + "std": float(np.std(data)), + "min": float(np.min(data)), + "max": float(np.max(data)), + "median": float(np.median(data)), + "p25": float(np.percentile(data, 25)), + "p75": float(np.percentile(data, 75)), + "p90": float(np.percentile(data, 90)), + "p95": float(np.percentile(data, 95)), + # Convert to CUF for solar + "cuf_pct": float(np.mean(data) * 100) if kind == "solar" else None, + } \ No newline at end of file diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx index 4b33436..1aa252c 100644 --- a/packages/web/app/page.tsx +++ b/packages/web/app/page.tsx @@ -12,6 +12,7 @@ import { } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { ScenarioWizard } from "@/components/ScenarioWizard"; +import { ProfileViewer } from "@/components/ProfileViewer"; function StatusBadge({ status }: { status: string }) { const styles: Record = { @@ -133,6 +134,7 @@ export default function HomePage() {

+
diff --git a/packages/web/components/ProfileViewer.tsx b/packages/web/components/ProfileViewer.tsx new file mode 100644 index 0000000..067180e --- /dev/null +++ b/packages/web/components/ProfileViewer.tsx @@ -0,0 +1,515 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + ReferenceLine, + LineChart, + Line, + CartesianGrid, + Legend, +} from "recharts"; +import { + getAvailableProfiles, + getProfileStats, + getSolarProfile, + getWindProfile, + profileCsvUrl, + type ProfileStats, +} from "@/lib/api"; + +interface ProfileData { + solar: Record; // { locationId: [8760 values] } + wind: Record; + stats: Record; +} + +const LOCATIONS = [ + { id: "GJ", name: "Gujarat" }, + { id: "KA", name: "Karnataka" }, + { id: "RJ", name: "Rajasthan" }, +]; + +const SOLAR_COLOR = "#f97316"; // orange-500 +const WIND_COLOR = "#3b82f6"; // blue-500 +const UPLOAD_COLOR = "#8b5cf6"; // violet-500 + +interface StatsCardProps { + label: string; + value: string; + color?: string; +} + +function StatsCard({ label, value, color }: StatsCardProps) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +// Custom tooltip for charts +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number; name: string }[]; label?: string }) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry, i) => ( +
+ + {entry.name}: + {(entry.value * 100).toFixed(2)}% +
+ ))} +
+ ); +} + +export function ProfileViewer() { + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState<"solar" | "wind" | "compare">("solar"); + const [uploadedProfiles, setUploadedProfiles] = useState<{ + solar?: number[]; + wind?: number[]; + filename?: string; + }>({}); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + // Calculate stats for a profile + const calculateStats = (data: number[]) => { + const sorted = [...data].sort((a, b) => a - b); + const sum = data.reduce((a, b) => a + b, 0); + return { + avg: sum / data.length, + max: Math.max(...data), + min: Math.min(...data), + median: sorted[Math.floor(sorted.length / 2)], + }; + }; + + // Handle file upload + const handleFileUpload = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + setUploadError(null); + + try { + const text = await file.text(); + const lines = text.trim().split("\n"); + + if (lines.length < 2) { + throw new Error("File must have at least a header row and one data row"); + } + + // Try to detect format + const header = lines[0].toLowerCase(); + let parsedData: number[] = []; + + if (header.includes("%_generation")) { + // New format: find the %_generation column + const cols = header.split(","); + const colIdx = cols.indexOf("%_generation"); + if (colIdx === -1) throw new Error("Could not find %_generation column"); + + parsedData = lines.slice(1).map((line) => { + const vals = line.split(","); + let val = parseFloat(vals[colIdx]); + if (isNaN(val)) return 0; + if (val > 1) val = val / 100; // Convert % to fraction + return val; + }); + } else if (header.includes("irradiance") || header.includes("wind_speed") || header.includes("e_grid")) { + // Old format: usually second column + parsedData = lines.slice(1).map((line) => { + const vals = line.split(","); + let val = parseFloat(vals[1]); + if (isNaN(val)) return 0; + // Handle different scales + if (val > 1) val = val / 1000; // Convert kW to MW + if (val > 1) val = val / 100; // Convert % to fraction + return val; + }); + } else { + // Generic: just take all numeric values from first column + parsedData = lines.slice(1).map((line) => { + const vals = line.split(","); + let val = parseFloat(vals[0]); + if (isNaN(val)) return 0; + return val; + }); + } + + // Validate 8760 values + if (parsedData.length !== 8760) { + throw new Error(`Expected 8760 values, got ${parsedData.length}`); + } + + // Determine if solar or wind based on average value (solar typically higher during day) + const isSolar = parsedData.reduce((a, b, i) => { + // Solar has high values during midday hours (indices 6-18 of each day) + const hourOfDay = i % 24; + if (hourOfDay >= 6 && hourOfDay <= 18) return a + b; + return a; + }, 0) / parsedData.reduce((a, b) => a + b, 0) > 0.7; + + setUploadedProfiles({ + solar: isSolar ? parsedData : undefined, + wind: !isSolar ? parsedData : undefined, + filename: file.name, + }); + } catch (err) { + setUploadError(err instanceof Error ? err.message : "Failed to parse file"); + } finally { + setIsUploading(false); + } + }, []); + + // Generate monthly averages for chart + const getMonthlyAverages = (data: number[], isSolar: boolean) => { + const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const monthNames = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"]; + const monthPositions = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3]; + + return monthNames.map((name, i) => { + const month = monthPositions[i]; + const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24; + const days = MONTH_DAYS[month - 1]; + const monthData = data.slice(start, start + days * 24); + const avg = monthData.reduce((a, b) => a + b, 0) / monthData.length; + return { name, avg: avg * 100 }; + }); + }; + + // Generate hourly average profile for a typical day + const getHourlyAverage = (data: number[]) => { + const hourly = new Array(24).fill(0); + for (let i = 0; i < 8760; i++) { + hourly[i % 24] += data[i]; + } + return hourly.map((v) => v / 365); + }; + + return ( + <> + {/* Button to open the modal */} + + + {/* Modal */} + {isOpen && ( +
+ {/* Backdrop */} +
setIsOpen(false)} + /> + + {/* Modal content */} +
+ {/* Header */} +
+

CUF Profile Viewer

+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Upload section */} +
+
+ + {uploadError && ( + {uploadError} + )} +
+
+ + {/* Content */} +
+ {activeTab === "solar" && ( +
+
+

+ Monthly Average Solar Irradiance +

+
+ + { + // Mock data for demonstration - in real app this would come from API + const mockData = Array(8760).fill(0).map((_, i) => { + const hour = i % 24; + const dayOfYear = Math.floor(i / 24); + // Simple sinusoidal approximation + const solarNoon = Math.sin((hour - 6) * Math.PI / 12); + const seasonFactor = Math.sin((dayOfYear - 80) * 2 * Math.PI / 365); + return Math.max(0, solarNoon * (0.8 + 0.2 * seasonFactor)); + }); + const monthly = getMonthlyAverages(mockData, true); + const idx = ["GJ", "KA", "RJ"].indexOf(loc.id); + return { + name: loc.name, + Jan: monthly[9]?.avg || 0, + Feb: monthly[10]?.avg || 0, + Mar: monthly[11]?.avg || 0, + Apr: monthly[0]?.avg || 0, + May: monthly[1]?.avg || 0, + Jun: monthly[2]?.avg || 0, + Jul: monthly[3]?.avg || 0, + Aug: monthly[4]?.avg || 0, + Sep: monthly[5]?.avg || 0, + Oct: monthly[6]?.avg || 0, + Nov: monthly[7]?.avg || 0, + Dec: monthly[8]?.avg || 0, + [idx]: 0, + }; + })}> + + + } /> + + + + + + +
+
+ + {uploadedProfiles.solar && ( +
+

+ Your Uploaded Solar Profile +

+
+ {(() => { + const stats = calculateStats(uploadedProfiles.solar!); + return ( + <> + + + + + + ); + })()} +
+
+ )} +
+ )} + + {activeTab === "wind" && ( +
+
+

+ Monthly Average Wind Speed (m/s) +

+
+ + + + + + + + +
+
+ + {uploadedProfiles.wind && ( +
+

+ Your Uploaded Wind Profile +

+
+ {(() => { + const stats = calculateStats(uploadedProfiles.wind!); + return ( + <> + + + + + + ); + })()} +
+
+ )} +
+ )} + + {activeTab === "compare" && ( +
+
+

+ Solar vs Wind CUF (Typical Daily Pattern) +

+
+ + { + const hour = i % 24; + const solarValue = Math.max(0, Math.sin((hour - 6) * Math.PI / 12)); + const windValue = 0.3 + 0.2 * Math.sin(i * Math.PI / 12) + 0.1 * Math.random(); + return (solarValue + windValue) / 2; + }) + ).map((v, i) => ({ hour: `${String(i).padStart(2, '0')}:00`, Solar: v, Wind: 0.3 + 0.1 * Math.sin(i * Math.PI / 12) }))}> + + + `${(v * 100).toFixed(0)}%`} /> + } /> + + + + + +
+
+ + {uploadedProfiles.solar && uploadedProfiles.wind && ( +
+

+ Your Uploaded Profiles Comparison +

+
+ {(() => { + const solarStats = calculateStats(uploadedProfiles.solar!); + const windStats = calculateStats(uploadedProfiles.wind!); + return ( + <> + + + + + + ); + })()} +
+
+ )} +
+ )} +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts index aca0de5..b20ac5a 100644 --- a/packages/web/lib/api.ts +++ b/packages/web/lib/api.ts @@ -367,3 +367,55 @@ export function scenarioEventsUrl(id: string): string { export function scenarioExcelUrl(id: string): string { return `${API_BASE}/api/scenarios/${id}/export/excel`; } + +// --------------------------------------------------------------------------- +// Profile functions +// --------------------------------------------------------------------------- + +export interface ProfileInfo { + id: string; + name: string; + path: string; +} + +export interface ProfileStats { + kind: string; + location_id: string; + count: number; + avg: number; + std: number; + min: number; + max: number; + median: number; + p25: number; + p75: number; + p90: number; + p95: number; + cuf_pct: number | null; +} + +export interface ProfileData { + location_id: string; + values: number[]; + count: number; +} + +export async function getAvailableProfiles(): Promise<{ solar: ProfileInfo[]; wind: ProfileInfo[] }> { + return apiFetch<{ solar: ProfileInfo[]; wind: ProfileInfo[] }>("/api/profiles/available"); +} + +export async function getProfileStats(kind: string, locationId: string): Promise { + return apiFetch(`/api/profiles/stats/${kind}/${locationId}`); +} + +export async function getSolarProfile(locationId: string): Promise { + return apiFetch(`/api/profiles/solar/${locationId}`); +} + +export async function getWindProfile(locationId: string): Promise { + return apiFetch(`/api/profiles/wind/${locationId}`); +} + +export function profileCsvUrl(kind: string, locationId: string): string { + return `${API_BASE}/api/profiles/${kind}/${locationId}?as_csv=true`; +}