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`;
+}