Skip to content
Merged
822 changes: 683 additions & 139 deletions src/app/components/BuildingConfigurator/BuildingConfigurator.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

import React from 'react';
import { ScrollHintContainer } from '../shared/ui';
import { AlertTriangle, Zap, Flame, Droplets, Gauge } from 'lucide-react';
import { AlertTriangle, Zap, Flame, Snowflake, Gauge } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EnergyTotals } from '../../../lib/loadProfile';
import type { EnergySource, EnergyTotals } from '../../../lib/loadProfile';
import { SnapshotRow, SnapshotStatusBadge } from '../shared/snapshotUtils';
import { HeatingDeltaBadge } from '../shared/ui';
import { SourceTag, EnergyComparisonNote } from '../shared/ui';

interface PvSummary {
installed: boolean;
Expand All @@ -32,7 +32,7 @@ export interface BuildingSnapshotAsideProps {
const ENERGY_ITEMS = [
{ key: 'heating', label: 'Heating', Icon: Flame, iconBg: 'bg-orange-500/20', iconColor: 'text-orange-400', valueColor: 'text-orange-300' },
{ key: 'electricity', label: 'Electricity', Icon: Zap, iconBg: 'bg-yellow-500/20', iconColor: 'text-yellow-400', valueColor: 'text-yellow-300' },
{ key: 'hotwater', label: 'Hot Water', Icon: Droplets, iconBg: 'bg-blue-500/20', iconColor: 'text-blue-400', valueColor: 'text-blue-300' },
{ key: 'hotwater', label: 'Cooling', Icon: Snowflake, iconBg: 'bg-blue-500/20', iconColor: 'text-blue-400', valueColor: 'text-blue-300' },
] as const;

/** Left panel of the overview: energy hero numbers + building parameters table. */
Expand Down Expand Up @@ -68,27 +68,30 @@ export function BuildingSnapshotAside({
<div className="flex flex-col gap-3">
{ENERGY_ITEMS.map(({ key, label, Icon, iconBg, iconColor, valueColor }) => {
const value = energyTotals[key];
const source = energyTotals[`${key}Source` as keyof EnergyTotals] as EnergySource | undefined;
const deltaPercent = energyTotals[`${key}DeltaPercent` as keyof EnergyTotals] as number | null | undefined;
const referenceKwh = energyTotals[`${key}BaselineKwh` as keyof EnergyTotals] as string | undefined;
const referenceLabel = energyTotals[`${key}ComparisonLabel` as keyof EnergyTotals] as string | undefined;
return (
<div key={key} className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className={cn('flex size-7 items-center justify-center rounded-md', iconBg)}>
<Icon className={cn('size-4', iconColor)} />
</div>
<span className="text-sm text-slate-300">{label}</span>
<SourceTag source={source} />
</div>
<div className="text-right">
<div>
{key === 'heating' && energyTotals.heatingPerM2 && (
<span className="mr-1.5 text-[11px] text-slate-500">({energyTotals.heatingPerM2} kWh/m²·a)</span>
)}
<span className={cn('text-xl font-bold leading-none', value === '—' ? 'text-slate-500' : valueColor)}>
{value}
</span>
<span className="ml-1.5 text-[11px] text-slate-500">{energyTotals.unit}</span>
{key === 'heating' && (
<HeatingDeltaBadge deltaPercent={energyTotals.heatingDeltaPercent} />
)}
</div>
{key === 'heating' && energyTotals.heatingPerM2 && (
<p className="text-[11px] text-slate-500">{energyTotals.heatingPerM2} kWh/m²·a</p>
)}
<EnergyComparisonNote deltaPercent={deltaPercent} referenceKwh={referenceKwh} referenceLabel={referenceLabel} />
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface EnergyEnvelopeColumnProps {
pvSummary: PvSummary;
onToggleTech?: (id: string, installed: boolean) => void;
onOpenTech?: (id: string) => void;
/** Fired when the user uploads a load profile — becomes ground truth for the annual totals elsewhere. */
onGroundTruthChange?: (rows: LoadDataPoint[] | null, label: string | null) => void;
}

/** Right panel of the overview: energy chart primary, element composition secondary, technologies tertiary. */
Expand All @@ -49,6 +51,7 @@ export function EnergyEnvelopeColumn({
pvSummary,
onToggleTech,
onOpenTech,
onGroundTruthChange,
}: EnergyEnvelopeColumnProps) {
return (
<ScrollHintContainer className="flex flex-col bg-slate-100
Expand All @@ -75,6 +78,7 @@ export function EnergyEnvelopeColumn({
buildingId={buildingId}
initialTimeseries={initialTimeseries ?? undefined}
mode={mode}
onGroundTruthChange={onGroundTruthChange}
/>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef, type ElementType } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, Legend, ResponsiveContainer, Brush } from 'recharts';
import { Download, Upload, Zap, Flame, Droplets, Layers3 } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, Legend, ResponsiveContainer, Brush, ReferenceLine } from 'recharts';
import { Download, Upload, Zap, Flame, Snowflake, Layers3 } from 'lucide-react';
import { T, SegmentedControl } from '../shared/ui';
import {
formatEnergyValue,
Expand All @@ -20,9 +20,11 @@ interface LoadProfileViewerProps {
initialTimeseries?: LoadDataPoint[];
/** Controls UI complexity. Basic hides expert controls; defaults to basic. */
mode?: 'basic' | 'expert';
/** Fired when the user uploads a file — the uploaded rows become ground truth for the annual totals elsewhere in the app. */
onGroundTruthChange?: (rows: LoadDataPoint[] | null, label: string | null) => void;
}

export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, initialTimeseries, mode = 'basic' }: LoadProfileViewerProps) {
export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, initialTimeseries, mode = 'basic', onGroundTruthChange }: LoadProfileViewerProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const {
availableHourlyDates,
Expand All @@ -45,10 +47,17 @@ export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, i
showBrush,
sourceCaption,
stepLabel,
unit,
uploadError,
visibleRangeLabel,
windowMode,
} = useLoadProfileState({ buildingId, initialTimeseries, mode, onTotalsChange });
} = useLoadProfileState({ buildingId, initialTimeseries, mode, onTotalsChange, onGroundTruthChange });

// Mean of electricity + heating + cooling per visible point — only meaningful
// once all three series are plotted together, so it's Combined-only.
const combinedAverage = energyType === 'combined' && data.length > 0
? data.reduce((sum, point) => sum + point.electricity + point.heating + point.hotwater, 0) / data.length
: null;

// Shorter labels that communicate "what time period each data point covers"
const resolutionOptions = [
Expand All @@ -66,7 +75,7 @@ export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, i
const ENERGY_META: Record<EnergyType, { label: string; Icon: ElementType }> = {
electricity: { label: 'Electricity', Icon: Zap },
heating: { label: 'Heating', Icon: Flame },
hotwater: { label: 'Hot Water', Icon: Droplets },
hotwater: { label: 'Cooling', Icon: Snowflake },
combined: { label: 'Combined', Icon: Layers3 },
};

Expand Down Expand Up @@ -96,39 +105,35 @@ export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, i
</div>
{/* Resolution — labelled as the time period each point covers */}
<SegmentedControl options={resolutionOptions} value={resolution} onChange={(v) => setResolution(v as Resolution)} />
{/* Graph-data import / export — expert mode only */}
{mode === 'expert' && (
<>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px', borderRadius: 5,
border: `1px solid ${T.border}`, background: 'transparent',
color: T.foreground, cursor: 'pointer', fontSize: 11, fontWeight: 600, flexShrink: 0,
}}
>
<Upload size={12} /> Upload load profile
</button>
<button
type="button"
onClick={handleDownload}
disabled={!hasData}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px', borderRadius: 5,
border: `1px solid ${T.border}`, background: 'transparent',
color: hasData ? T.foreground : T.mutedFg,
cursor: hasData ? 'pointer' : 'not-allowed',
fontSize: 11, fontWeight: 600, flexShrink: 0, opacity: hasData ? 1 : 0.5,
}}
>
<Download size={12} /> Download profiles (.zip)
</button>
<input ref={fileInputRef} type="file" accept=".json,.csv" style={{ display: 'none' }} onChange={handleFileUpload} />
</>
)}
{/* Graph-data import / export — plain CSV in a zip, safe for any user to open in Excel */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px', borderRadius: 5,
border: `1px solid ${T.border}`, background: 'transparent',
color: T.foreground, cursor: 'pointer', fontSize: 11, fontWeight: 600, flexShrink: 0,
}}
>
<Upload size={12} /> Upload load profile
</button>
<button
type="button"
onClick={handleDownload}
disabled={!hasData}
style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
height: 26, padding: '0 9px', borderRadius: 5,
border: `1px solid ${T.border}`, background: 'transparent',
color: hasData ? T.foreground : T.mutedFg,
cursor: hasData ? 'pointer' : 'not-allowed',
fontSize: 11, fontWeight: 600, flexShrink: 0, opacity: hasData ? 1 : 0.5,
}}
>
<Download size={12} /> Download profiles (.zip)
</button>
<input ref={fileInputRef} type="file" accept=".json,.csv" style={{ display: 'none' }} onChange={handleFileUpload} />
</div>

{hasData && mode === 'expert' && (
Expand Down Expand Up @@ -271,13 +276,22 @@ export function LoadProfileViewer({ buildingId = 'Building 3', onTotalsChange, i
/>
<Legend wrapperStyle={{ fontSize: 9 }} iconType="line" iconSize={8} />
{(energyType === 'electricity' || energyType === 'combined') && (
<Line type="monotone" dataKey="electricity" stroke="#3b82f6" strokeWidth={2} name="Electricity" dot={false} activeDot={{ r: 4 }} />
<Line type="monotone" dataKey="electricity" stroke="#f59e0b" strokeWidth={2} name="Electricity" dot={false} activeDot={{ r: 4 }} />
)}
{(energyType === 'heating' || energyType === 'combined') && (
<Line type="monotone" dataKey="heating" stroke="#ef4444" strokeWidth={2} name="Heating" dot={false} activeDot={{ r: 4 }} />
)}
{(energyType === 'hotwater' || energyType === 'combined') && (
<Line type="monotone" dataKey="hotwater" stroke="#f59e0b" strokeWidth={2} name="Hot Water" dot={false} activeDot={{ r: 4 }} />
<Line type="monotone" dataKey="hotwater" stroke="#3b82f6" strokeWidth={2} name="Cooling" dot={false} activeDot={{ r: 4 }} />
)}
{combinedAverage !== null && (
<ReferenceLine
y={combinedAverage}
stroke="#64748b"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `Avg ${formatEnergyValue(combinedAverage, 4)} ${unit}`, position: 'insideTopRight', fontSize: 10, fill: '#64748b' }}
/>
)}
{showBrush && mode === 'expert' && (
<Brush
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getVisibleRangeLabel,
mergeUploadedData,
parseCsv,
pickUpdatedRows,
rangeEquals,
toProfileZip,
toUtcDateKey,
Expand All @@ -29,6 +30,8 @@ interface UseLoadProfileStateArgs {
initialTimeseries?: LoadDataPoint[];
mode?: 'basic' | 'expert';
onTotalsChange?: (totals: EnergyTotals) => void;
/** Fired whenever the user uploads a file — the uploaded rows become ground truth for the annual totals elsewhere in the app. */
onGroundTruthChange?: (rows: LoadDataPoint[] | null, label: string | null) => void;
}

/** Owns viewer-specific state while delegating parsing and aggregation to pure helpers. */
Expand All @@ -37,6 +40,7 @@ export function useLoadProfileState({
initialTimeseries,
mode = 'basic',
onTotalsChange,
onGroundTruthChange,
}: UseLoadProfileStateArgs) {
const [energyType, setEnergyType] = useState<EnergyType>(mode === 'basic' ? 'combined' : 'electricity');
const [resolution, setResolution] = useState<Resolution>(mode === 'basic' ? 'monthly' : 'daily');
Expand Down Expand Up @@ -220,6 +224,7 @@ export function useLoadProfileState({

setDataset(nextDataset);
setSourceLabel(file.name);
onGroundTruthChange?.(pickUpdatedRows(dataset, nextDataset), file.name);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not parse load profile file.';
setUploadError(message);
Expand Down
56 changes: 39 additions & 17 deletions src/app/components/BuildingConfigurator/shared/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export const ELEMENT_DOTS: Record<string, string> = {
door: '#8a5a38',
};

// ─── ConfiguratorStyles — no-op; styles are now in theme.css ──────────────────

/** @deprecated Styles are now in theme.css. This component is kept for compatibility. */
export function ConfiguratorStyles() {
return null;
}

// ─── InfoTip ──────────────────────────────────────────────────────────────────

export function InfoTip({ tip }: { tip: string }) {
Expand Down Expand Up @@ -77,34 +84,49 @@ export function InfoTip({ tip }: { tip: string }) {
// ─── HeatingDeltaBadge ────────────────────────────────────────────────────────

/**
* Small "▲/▼ N%" pill showing how a live ignis heating figure compares to the
* last full BuEM simulation. Lower demand is good (green); higher is a
* regression to flag (red). Renders nothing if there's no baseline to compare
* against, or the values are effectively unchanged.
*
* Percentages beyond DELTA_DISPLAY_CAP are shown as ">300%" rather than the
* raw figure — while BuEM and the building's data are still placeholders, a
* comparison between two disconnected estimates can produce swings in the
* thousands of percent, which is noise, not a real signal, and reads as broken.
*/
const DELTA_DISPLAY_CAP = 300;

export function HeatingDeltaBadge({ deltaPercent }: { deltaPercent: number | null | undefined }) {
if (deltaPercent === null || deltaPercent === undefined || Math.abs(deltaPercent) < 0.5) return null;
/**
* Small tag next to an energy row's label naming where its number came
* from. Nothing is shown for 'buem' — a confirmed full simulation is the
* ordinary/default state and doesn't need calling out.
*/
export function SourceTag({ source }: { source?: 'ignis' | 'buem' | 'user' }) {
if (source === 'ignis') return <span className="ml-1.5 text-[10px] font-medium text-slate-400">estimated</span>;
if (source === 'user') return <span className="ml-1.5 text-[10px] font-medium text-slate-400">user defined</span>;
return null;
}

/**
* One-line plain-language comparison — "26% lower than the last full
* simulation (24,955 kWh)" — replacing a bare percentage badge with wording
* a non-CS reader can act on. Caller decides when there's something worth
* saying (e.g. only while the number differs from a reference); this just
* renders it.
*/
export function EnergyComparisonNote({
deltaPercent, referenceKwh, referenceLabel,
}: {
deltaPercent?: number | null;
referenceKwh?: string;
referenceLabel?: string;
}) {
if (deltaPercent === null || deltaPercent === undefined || !referenceKwh || !referenceLabel) return null;
if (Math.abs(deltaPercent) < 0.5) return null;

const isIncrease = deltaPercent > 0;
const magnitude = Math.abs(deltaPercent);
const displayValue = magnitude > DELTA_DISPLAY_CAP ? `>${DELTA_DISPLAY_CAP}%` : `${magnitude.toFixed(0)}%`;
const displayValue = magnitude > DELTA_DISPLAY_CAP ? `>${DELTA_DISPLAY_CAP}` : magnitude.toFixed(0);

return (
<span
className={cn(
'ml-1.5 text-[10px] font-semibold',
isIncrease ? 'text-red-400' : 'text-emerald-400',
)}
title="vs. last full simulation"
>
{isIncrease ? '▲' : '▼'} {displayValue}
</span>
<p className={cn('text-[11px] leading-snug', isIncrease ? 'text-red-400' : 'text-emerald-400')}>
{displayValue}% {isIncrease ? 'higher' : 'lower'} than {referenceLabel} ({referenceKwh} kWh)
</p>
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/app/lib/buemAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,11 @@ export function serializeToBuemFeature(
building,
solver: {
use_milp: general.use_milp ?? false,
// Summer solar gains through real windows can plausibly need active
// cooling, not just heating — without this BuEM never computes a
// cooling load at all (not zero — the field is simply absent from
// the response), regardless of the building's actual exposure.
compute_cooling: general.compute_cooling ?? true,
},
},
...(Object.keys(techs).length > 0 ? { techs } : {}),
Expand Down Expand Up @@ -629,6 +634,7 @@ export function parseBuemFeatureForImport(feature: unknown): ImportedBuildingDat
massClass: (thermalBlk.thermal_class === 'medium' ? 'Medium' :
thermalBlk.thermal_class === 'heavy' ? 'Heavy' : 'Light'),
use_milp: ((buem.solver ?? {}) as Record<string, unknown>).use_milp === true,
compute_cooling: ((buem.solver ?? {}) as Record<string, unknown>).compute_cooling !== false,
};

return { elements, general, isBuemFormat: true };
Expand Down
6 changes: 5 additions & 1 deletion src/app/lib/buemApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ function toSimulationResult(profile: BuemThermalLoadProfile): BuemSimulationResu
// cooling output matches the existing thermalSummary-based fallback
// in BuildingConfigurator.tsx's computeEnergyTotals — not a
// physically accurate label, but the established convention.
hotwater: ts.cooling?.[i] ?? 0,
// BuEM reports cooling as negative (its solver shares one signed
// Q_HC axis: positive = heat added, negative = heat removed) — flip
// it here so every downstream reader (chart, CSV export, totals)
// sees a plain positive "energy needed for cooling" figure.
hotwater: -(ts.cooling?.[i] ?? 0),
}))
: [];

Expand Down
Loading