Remodel/packages/web/components/ProfileViewer.tsx
Manohar Gupta 112c25c26b
Some checks are pending
CI / Engine — lint / typecheck / test (push) Waiting to run
CI / API — lint / typecheck / test (push) Waiting to run
CI / Web — typecheck / lint / build (push) Waiting to run
Add CUF profile viewer with upload capability
2026-05-23 17:22:31 +05:30

515 lines
No EOL
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)}
</>
);
}