Skip to content
Merged
154 changes: 125 additions & 29 deletions src/app/components/BuildingConfigurator/BuildingConfigurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import {
Download, Upload, X, Building2, RotateCcw, Check, AlertTriangle,
Flame, Zap, Droplets, Gauge, LayoutDashboard, SlidersHorizontal,
Flame, Zap, Snowflake, Gauge, LayoutDashboard, SlidersHorizontal,
} from 'lucide-react';

import { BuildingVisualization, VIEW_ORDER } from './configure/visualization/BuildingVisualization';
Expand All @@ -17,9 +17,9 @@ import {
faceFromAzimuth,
} from './configure/model/buildingElements';
Comment on lines +13 to +18
import { type RoofConfig, DEFAULT_ROOF_CONFIG } from './configure/model/roof';
import { SegmentedControl, ConfiguratorStyles, ScrollHintContainer, HeatingDeltaBadge } from './shared/ui';
import { SegmentedControl, ConfiguratorStyles, ScrollHintContainer, SourceTag, EnergyComparisonNote } from './shared/ui';
import { cn } from '../../../lib/utils';
import { type EnergyTotals, type LoadDataPoint } from '../../lib/loadProfile';
import { type EnergySource, type EnergyTotals, type LoadDataPoint } from '../../lib/loadProfile';

import { DEFAULT_ELEMENTS, DEFAULT_GENERAL, computeTotalFloorArea } from './shared/buildingDefaults';
import type { BuildingState, ThermalSummary } from '../../lib/buemAdapter';
Expand Down Expand Up @@ -150,18 +150,18 @@ function computeEnergyTotals(
heating: formatKwh(timeseries.reduce((s, p) => s + p.heating, 0)),
electricity: formatKwh(timeseries.reduce((s, p) => s + p.electricity, 0)),
hotwater: formatKwh(timeseries.reduce((s, p) => s + p.hotwater, 0)),
unit: 'kWh',
unit: 'kWh/year',
};
}
if (thermalSummary) {
return {
heating: thermalSummary.heatingKwh.toFixed(0),
electricity: thermalSummary.electricityKwh.toFixed(0),
hotwater: thermalSummary.coolingKwh.toFixed(0),
unit: 'kWh',
unit: 'kWh/year',
};
}
return { electricity: '—', heating: '—', hotwater: '—', unit: 'kWh' };
return { electricity: '—', heating: '—', hotwater: '—', unit: 'kWh/year' };
}

/**
Expand All @@ -181,12 +181,95 @@ function baselineHeatingKwh(
return null;
}

/**
* Resolves what the three energy cards should actually show, given every
* source that might feed them: BuEM's last confirmed run, ignis's live
* heating estimate (heating only — ignis has no electricity/cooling model),
* and — if the user uploaded one — a real load profile. An uploaded profile
* is real data, not a model guess, so it outranks both as the headline
* number; the model outputs then become the thing being compared *against*
* it instead of the other way around.
*/
function resolveDisplayEnergyTotals(
energyTotals: EnergyTotals,
groundTruthTimeseries: LoadDataPoint[] | null,
ignisResult: { qHnd: number } | null | undefined,
totalFloorArea: number,
buemBaselineHeatingKwh: number | null,
isHeatingConfirmed: boolean,
): EnergyTotals {
const groundTruth = groundTruthTimeseries ? computeEnergyTotals(groundTruthTimeseries, null) : null;

const ignisHeatingKwh = ignisResult && totalFloorArea > 0 ? ignisResult.qHnd * totalFloorArea : null;
const currentHeatingKwh = ignisHeatingKwh === null || isHeatingConfirmed ? Number(energyTotals.heating) : ignisHeatingKwh;
const currentHeatingSource: 'ignis' | 'buem' = ignisHeatingKwh === null || isHeatingConfirmed ? 'buem' : 'ignis';
const currentHeatingPerM2 = ignisHeatingKwh !== null && !isHeatingConfirmed ? ignisResult!.qHnd.toFixed(1) : undefined;

if (groundTruth) {
// Ground truth always wins as the headline — compare whichever model
// figure is "current" right now (ignis's live estimate, or BuEM's
// confirmed result once Recalculate has run) against it.
const heatingRefKwh = currentHeatingKwh > 0
? ((Number(groundTruth.heating) - currentHeatingKwh) / currentHeatingKwh) * 100
: null;
const electricityKwh = Number(energyTotals.electricity);
const electricityDeltaPercent = electricityKwh > 0
? ((Number(groundTruth.electricity) - electricityKwh) / electricityKwh) * 100
: null;
const hotwaterKwh = Number(energyTotals.hotwater);
const hotwaterDeltaPercent = hotwaterKwh > 0
? ((Number(groundTruth.hotwater) - hotwaterKwh) / hotwaterKwh) * 100
: null;
const heatingLabel = currentHeatingSource === 'ignis' ? "ignis's live estimate" : 'the last full simulation';

return {
...groundTruth,
heatingSource: 'user',
electricitySource: 'user',
hotwaterSource: 'user',
heatingDeltaPercent: heatingRefKwh,
heatingBaselineKwh: currentHeatingKwh > 0 ? formatKwh(currentHeatingKwh) : undefined,
heatingComparisonLabel: currentHeatingKwh > 0 ? heatingLabel : undefined,
electricityDeltaPercent,
electricityBaselineKwh: electricityKwh > 0 ? formatKwh(electricityKwh) : undefined,
electricityComparisonLabel: electricityKwh > 0 ? 'the last full simulation' : undefined,
hotwaterDeltaPercent,
hotwaterBaselineKwh: hotwaterKwh > 0 ? formatKwh(hotwaterKwh) : undefined,
hotwaterComparisonLabel: hotwaterKwh > 0 ? 'the last full simulation' : undefined,
};
Comment thread
Copilot marked this conversation as resolved.
}

if (ignisHeatingKwh === null) return energyTotals;

if (isHeatingConfirmed) {
// Right after Recalculate, with no edits since, energyTotals.heating is
// already BuEM's confirmed result for the current inputs — show that
// instead of ignis's fast estimate, since it's the number the user just
// ran a real physics simulation to get.
return { ...energyTotals, heatingSource: 'buem', heatingDeltaPercent: null, heatingPerM2: undefined };
}

const heatingDeltaPercent = buemBaselineHeatingKwh && buemBaselineHeatingKwh > 0
? ((ignisHeatingKwh - buemBaselineHeatingKwh) / buemBaselineHeatingKwh) * 100
: null;

return {
...energyTotals,
heating: formatKwh(ignisHeatingKwh),
heatingSource: 'ignis',
heatingDeltaPercent,
heatingPerM2: currentHeatingPerM2,
heatingBaselineKwh: buemBaselineHeatingKwh && buemBaselineHeatingKwh > 0 ? formatKwh(buemBaselineHeatingKwh) : undefined,
heatingComparisonLabel: 'the last full simulation',
};
}

// --- Energy items config (used in the configure view's demand mini panel) -----

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;

// --- Direction label helper ---------------------------------------------------
Expand Down Expand Up @@ -325,6 +408,13 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
// priority over whatever timeseries the buildingData prop originally carried.
const [modelTimeseries, setModelTimeseries] = useState<LoadDataPoint[] | null>(null);
const [isRunningSimulation, setIsRunningSimulation] = useState(false);
// True right after Recalculate, for as long as the ignis live estimate hasn't
// moved since — meaning BuEM's just-fetched result still reflects the current
// inputs and can be shown as the headline number instead of ignis's estimate.
const [isHeatingConfirmed, setIsHeatingConfirmed] = useState(false);
// A user-uploaded load profile, if any — outranks both ignis and BuEM as
// the annual totals' source, since it's real data rather than a model output.
const [groundTruthTimeseries, setGroundTruthTimeseries] = useState<LoadDataPoint[] | null>(null);

const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -489,6 +579,14 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ignis?.calcDemand, ignis?.selectedVariantIndex]);

// A fresh ignis result means the building's inputs moved since the last
// Recalculate — the BuEM figure on screen is no longer guaranteed current,
// so fall back to showing ignis's live estimate until the user confirms
// it again with another Recalculate.
useEffect(() => {
setIsHeatingConfirmed(false);
}, [ignis?.result]);

// ── HDCP handlers ─────────────────────────────────────────────────────────────

const handleIgnisFieldChange = (changes: Partial<IgnisInputs>) =>
Expand Down Expand Up @@ -833,6 +931,7 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
setModelTimeseries(result.timeseries);
setEnergyTotals(computeEnergyTotals(result.timeseries, result.thermalSummary));
setBuemBaselineHeatingKwh(baselineHeatingKwh(result.timeseries, result.thermalSummary));
setIsHeatingConfirmed(true);
} finally {
setIsRunningSimulation(false);
}
Expand Down Expand Up @@ -942,22 +1041,15 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
// level, field changes) move heating demand before deciding to save or revert.
const displayEnergyTotals: EnergyTotals = useMemo(() => {
const totalFloorArea = computeTotalFloorArea(Number(general.floorArea) || 0, Number(general.storeys) || 1);
const ignisResult = ignis?.result;
if (!ignisResult || totalFloorArea <= 0) return energyTotals;

const ignisHeatingKwh = ignisResult.qHnd * totalFloorArea;
const heatingDeltaPercent = buemBaselineHeatingKwh && buemBaselineHeatingKwh > 0
? ((ignisHeatingKwh - buemBaselineHeatingKwh) / buemBaselineHeatingKwh) * 100
: null;

return {
...energyTotals,
heating: formatKwh(ignisHeatingKwh),
heatingSource: 'ignis',
heatingDeltaPercent,
heatingPerM2: ignisResult.qHnd.toFixed(1),
};
}, [energyTotals, ignis?.result, general.floorArea, general.storeys, buemBaselineHeatingKwh]);
return resolveDisplayEnergyTotals(
energyTotals,
groundTruthTimeseries,
ignis?.result,
totalFloorArea,
buemBaselineHeatingKwh,
isHeatingConfirmed,
);
}, [energyTotals, groundTruthTimeseries, ignis?.result, general.floorArea, general.storeys, buemBaselineHeatingKwh, isHeatingConfirmed]);
const pvInstalledSurfaces = useMemo(() => (
Object.values(elements)
.filter((element) => surfacePvConfigs[element.id]?.installed)
Expand Down Expand Up @@ -1109,6 +1201,7 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
isActive={workspaceView === 'overview'}
buildingId={buildingLabel}
initialTimeseries={modelTimeseries ?? thematicData?.timeseries ?? buildingData?.timeseries ?? null}
onGroundTruthChange={(rows) => setGroundTruthTimeseries(rows)}
mode={mode}
installedTechIds={installedTechIds}
pvSummary={pvSummary}
Expand Down Expand Up @@ -1155,27 +1248,30 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
<div className="flex flex-col gap-3">
{ENERGY_ITEMS.map(({ key, label, Icon, iconBg, iconColor, valueColor }) => {
const value = displayEnergyTotals[key as keyof EnergyTotals];
const source = displayEnergyTotals[`${key}Source` as keyof EnergyTotals] as EnergySource | undefined;
const deltaPercent = displayEnergyTotals[`${key}DeltaPercent` as keyof EnergyTotals] as number | null | undefined;
const referenceKwh = displayEnergyTotals[`${key}BaselineKwh` as keyof EnergyTotals] as string | undefined;
const referenceLabel = displayEnergyTotals[`${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">
<div className={cn('flex size-6 shrink-0 items-center justify-center rounded-md', iconBg)}>
<Icon className={cn('size-3.5', iconColor)} />
</div>
<span className="text-xs text-slate-300">{label}</span>
<SourceTag source={source} />
</div>
<div className="text-right">
<div>
{key === 'heating' && displayEnergyTotals.heatingPerM2 && (
<span className="mr-1 text-[10px] text-slate-500">({displayEnergyTotals.heatingPerM2} kWh/m²·a)</span>
)}
<span className={cn('text-lg font-bold leading-none', value === '—' ? 'text-slate-500' : valueColor)}>
{value}
</span>
<span className="ml-1 text-[10px] text-slate-500">{displayEnergyTotals.unit}</span>
{key === 'heating' && (
<HeatingDeltaBadge deltaPercent={displayEnergyTotals.heatingDeltaPercent} />
)}
</div>
{key === 'heating' && displayEnergyTotals.heatingPerM2 && (
<p className="text-[10px] text-slate-500">{displayEnergyTotals.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 @@ -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
Loading