Add CUF profile viewer with upload capability
This commit is contained in:
parent
680df1a597
commit
112c25c26b
5 changed files with 703 additions and 1 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
132
packages/api/src/remodel_api/routers/profiles.py
Normal file
132
packages/api/src/remodel_api/routers/profiles.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
|
|
@ -133,6 +134,7 @@ export default function HomePage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ProfileViewer />
|
||||
<Button variant="outline" onClick={() => router.push("/compare")}>Compare</Button>
|
||||
<Button onClick={() => setShowWizard(true)}>+ New Scenario</Button>
|
||||
</div>
|
||||
|
|
|
|||
515
packages/web/components/ProfileViewer.tsx
Normal file
515
packages/web/components/ProfileViewer.tsx
Normal file
|
|
@ -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<string, number[]>; // { locationId: [8760 values] }
|
||||
wind: Record<string, number[]>;
|
||||
stats: Record<string, { avg: number; max: number; min: number; median: number }>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-card border rounded-lg p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">{label}</div>
|
||||
<div className="text-lg font-semibold tabular-nums" style={{ color: color || "inherit" }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="bg-background border rounded-lg shadow-lg p-2 text-xs">
|
||||
<p className="font-medium mb-1">{label}</p>
|
||||
{payload.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: entry.name === "Solar" ? SOLAR_COLOR : WIND_COLOR }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{entry.name}:</span>
|
||||
<span className="font-medium">{(entry.value * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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<HTMLInputElement>) => {
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
View CUF Profiles
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="relative w-full max-w-5xl max-h-[90vh] mx-4 bg-background rounded-xl shadow-2xl overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">CUF Profile Viewer</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 p-2 border-b bg-muted/30">
|
||||
<button
|
||||
onClick={() => setActiveTab("solar")}
|
||||
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
|
||||
activeTab === "solar"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
☀️ Solar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("wind")}
|
||||
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
|
||||
activeTab === "wind"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
💨 Wind
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("compare")}
|
||||
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
|
||||
activeTab === "compare"
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "hover:bg-background/50"
|
||||
}`}
|
||||
>
|
||||
📊 Compare
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload section */}
|
||||
<div className="p-4 border-b bg-muted/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex-1">
|
||||
<span className="text-sm text-muted-foreground block mb-1">
|
||||
Upload custom profile (CSV with 8760 hourly values):
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
className="hidden"
|
||||
id="profile-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="profile-upload"
|
||||
className={`px-3 py-1.5 text-sm border rounded-md cursor-pointer hover:bg-accent transition-colors ${
|
||||
isUploading ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{isUploading ? "Parsing..." : "Choose File"}
|
||||
</label>
|
||||
{uploadedProfiles.filename && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
✓ {uploadedProfiles.filename} (
|
||||
{uploadedProfiles.solar ? "Solar" : "Wind"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
{uploadError && (
|
||||
<span className="text-sm text-destructive">{uploadError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === "solar" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Monthly Average Solar Irradiance
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={LOCATIONS.map((loc) => {
|
||||
// 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,
|
||||
};
|
||||
})}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} unit="%" />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine y={20} stroke="#94a3b8" strokeDasharray="3 3" label="20%" />
|
||||
<Bar dataKey="GJ" fill={SOLAR_COLOR} name="Gujarat" />
|
||||
<Bar dataKey="KA" fill="#ea580c" name="Karnataka" />
|
||||
<Bar dataKey="RJ" fill="#c2410c" name="Rajasthan" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedProfiles.solar && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Your Uploaded Solar Profile
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(() => {
|
||||
const stats = calculateStats(uploadedProfiles.solar!);
|
||||
return (
|
||||
<>
|
||||
<StatsCard label="Avg CUF" value={`${(stats.avg * 100).toFixed(2)}%`} color={SOLAR_COLOR} />
|
||||
<StatsCard label="Max" value={`${(stats.max * 100).toFixed(1)}%`} />
|
||||
<StatsCard label="Min" value={`${(stats.min * 100).toFixed(1)}%`} />
|
||||
<StatsCard label="Median" value={`${(stats.median * 100).toFixed(1)}%`} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "wind" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Monthly Average Wind Speed (m/s)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={[
|
||||
{ name: "Gujarat", avg: 6.5, max: 12, min: 3 },
|
||||
{ name: "Karnataka", avg: 7.2, max: 14, min: 4 },
|
||||
{ name: "Rajasthan", avg: 5.8, max: 11, min: 2 },
|
||||
]}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="avg" fill={WIND_COLOR} name="Avg Speed" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedProfiles.wind && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Your Uploaded Wind Profile
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(() => {
|
||||
const stats = calculateStats(uploadedProfiles.wind!);
|
||||
return (
|
||||
<>
|
||||
<StatsCard label="Avg Speed" value={`${(stats.avg * 10).toFixed(2)} m/s`} color={WIND_COLOR} />
|
||||
<StatsCard label="Max" value={`${(stats.max * 10).toFixed(1)} m/s`} />
|
||||
<StatsCard label="Min" value={`${(stats.min * 10).toFixed(1)} m/s`} />
|
||||
<StatsCard label="Median" value={`${(stats.median * 10).toFixed(1)} m/s`} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "compare" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Solar vs Wind CUF (Typical Daily Pattern)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={getHourlyAverage(
|
||||
Array(8760).fill(0).map((_, i) => {
|
||||
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) }))}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#94a3b8" />
|
||||
<XAxis dataKey="hour" tick={{ fontSize: 10 }} interval={3} />
|
||||
<YAxis tick={{ fontSize: 12 }} domain={[0, 1]} tickFormatter={(v) => `${(v * 100).toFixed(0)}%`} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line type="monotone" dataKey="Solar" stroke={SOLAR_COLOR} dot={false} name="Solar" />
|
||||
<Line type="monotone" dataKey="Wind" stroke={WIND_COLOR} dot={false} name="Wind" />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedProfiles.solar && uploadedProfiles.wind && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3 text-muted-foreground">
|
||||
Your Uploaded Profiles Comparison
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(() => {
|
||||
const solarStats = calculateStats(uploadedProfiles.solar!);
|
||||
const windStats = calculateStats(uploadedProfiles.wind!);
|
||||
return (
|
||||
<>
|
||||
<StatsCard label="Solar CUF" value={`${(solarStats.avg * 100).toFixed(2)}%`} color={SOLAR_COLOR} />
|
||||
<StatsCard label="Wind CUF" value={`${(windStats.avg * 100).toFixed(2)}%`} color={WIND_COLOR} />
|
||||
<StatsCard label="Combined" value={`${((solarStats.avg + windStats.avg) * 50).toFixed(2)}%`} />
|
||||
<StatsCard label="Upload" value={uploadedProfiles.filename || "-"} />
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ProfileStats> {
|
||||
return apiFetch<ProfileStats>(`/api/profiles/stats/${kind}/${locationId}`);
|
||||
}
|
||||
|
||||
export async function getSolarProfile(locationId: string): Promise<ProfileData> {
|
||||
return apiFetch<ProfileData>(`/api/profiles/solar/${locationId}`);
|
||||
}
|
||||
|
||||
export async function getWindProfile(locationId: string): Promise<ProfileData> {
|
||||
return apiFetch<ProfileData>(`/api/profiles/wind/${locationId}`);
|
||||
}
|
||||
|
||||
export function profileCsvUrl(kind: string, locationId: string): string {
|
||||
return `${API_BASE}/api/profiles/${kind}/${locationId}?as_csv=true`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue