Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions src/app/components/BuildingConfigurator/BuildingConfigurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
exportToBuemGeojson,
importBuildingData,
} from '../../lib/buemAdapter';
import { runBuildingSimulation } from '../../lib/buemApi';
import type { IgnisState, IgnisInputs, IgnisFieldMetadata } from '../../lib/ignisAdapter';
import {
initIgnisState,
Expand Down Expand Up @@ -315,6 +316,10 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
// The last full BuEM simulation's annual heating figure — a fixed reference point.
// Set once per loaded building; does not change as the user edits ignis inputs.
const [buemBaselineHeatingKwh, setBuemBaselineHeatingKwh] = useState<number | null>(initialBaselineHeatingKwh);
// Hourly timeseries from the most recent live buem-gateway run this session — takes
// priority over whatever timeseries the buildingData prop originally carried.
const [modelTimeseries, setModelTimeseries] = useState<LoadDataPoint[] | null>(null);
const [isRunningSimulation, setIsRunningSimulation] = useState(false);

const fileInputRef = useRef<HTMLInputElement>(null);
Comment on lines +319 to 324

Expand Down Expand Up @@ -758,9 +763,42 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
setUploadError(null);
};

const handleApply = () => {
console.log('Apply:', { elements, general, roofConfig });
/**
* Commits the working draft, then runs a full BuEM simulation via
* buem-gateway and feeds the resulting load profile into the overview
* chart. Demo-only wiring — see buemApi.ts's module doc for why this is
* a direct call rather than going through a backend/orchestration layer.
*/
const handleApply = async () => {
setSavedState({ elements, general, roofConfig });

const coordinates: [number, number] = geometryData?.coordinates ?? identityData?.coordinates ?? [11.5820, 48.1351];
const identity = {
id: identityData?.id ?? 'building-1',
label: identityData?.label ?? buildingLabel,
coordinates,
buildingType: general.buildingType,
constructionPeriod: general.constructionPeriod,
country: general.country,
floorArea: general.floorArea,
roomHeight: general.roomHeight,
storeys: general.storeys,
};

setIsRunningSimulation(true);
setUploadError(null);
try {
const result = await runBuildingSimulation(identity, elements, general, identity.id, batteryConfig);
if (!result) {
setUploadError('Simulation failed — buem-gateway is unreachable or rejected the request.');
return;
}
setModelTimeseries(result.timeseries);
setEnergyTotals(computeEnergyTotals(result.timeseries, result.thermalSummary));
setBuemBaselineHeatingKwh(baselineHeatingKwh(result.timeseries, result.thermalSummary));
} finally {
setIsRunningSimulation(false);
}
};

// --- JSON export to BUEM API format ----------------------------------------
Expand Down Expand Up @@ -1033,7 +1071,7 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
roofConfig={roofConfig}
isActive={workspaceView === 'overview'}
buildingId={buildingLabel}
initialTimeseries={thematicData?.timeseries ?? buildingData?.timeseries ?? null}
initialTimeseries={modelTimeseries ?? thematicData?.timeseries ?? buildingData?.timeseries ?? null}
mode={mode}
installedTechIds={installedTechIds}
pvSummary={pvSummary}
Expand Down Expand Up @@ -1284,10 +1322,11 @@ export function BuildingConfigurator({ onClose, buildingData }: BuildingConfigur
<button
type="button"
onClick={handleApply}
className="flex cursor-pointer items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-xs font-semibold text-primary-foreground transition-colors duration-100 hover:bg-primary/90 shadow-[0_10px_20px_rgba(47,93,138,0.22)]"
disabled={isRunningSimulation}
className="flex cursor-pointer items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-xs font-semibold text-primary-foreground transition-colors duration-100 hover:bg-primary/90 shadow-[0_10px_20px_rgba(47,93,138,0.22)] disabled:cursor-not-allowed disabled:opacity-60"
>
<Check className="size-3.5" />
Apply
{isRunningSimulation ? 'Running simulation…' : 'Apply'}
</button>
</div>
</div>
Expand Down
156 changes: 156 additions & 0 deletions src/app/lib/buemApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* buem-gateway API client.
*
* Runs BuEM for the current building and returns its load profile. The base
* URL is configured via the VITE_BUEM_API_URL environment variable
* (default: https://localhost:8443, buem-gateway's own local-dev default).
*
* Demo-only wiring: this is a direct frontend-to-service call, not the
* target architecture. In the real system Building Configurator only
* collects data — it flows through the EnerPlanET backend and an
* Orchestration layer that dispatches to BuEM (and other services), not
* through a call like this one. See buem-gateway's
* decisions/2026-07-24-buem-gateway-standalone-repo.md for the full
* reasoning. Kept here only to showcase the pieces working end-to-end ahead
* of that layer existing.
*/

import type { BuildingIdentity } from './buemAdapter';
import { serializeToBuemFeature } from './buemAdapter';
import type { LoadDataPoint } from './loadProfile';

const BASE_URL = (import.meta.env.VITE_BUEM_API_URL as string | undefined) ?? 'https://localhost:8443';

/**
* Identifies this app to the reverse proxy sitting in front of buem-gateway.
* Prototype-stage credential only — see the orchestration-layer decision
* note before this pattern is carried into production (same caveat as
* ignisApi.ts's identical header).
*/
const AUTH_HEADERS: Record<string, string> = import.meta.env.VITE_BUEM_API_KEY
? { 'X-Api-Key': import.meta.env.VITE_BUEM_API_KEY as string }
: {};

/** A {value, unit} measurement, as buem-gateway's response shapes them. */
interface BuemQuantity {
value: number;
unit: string;
}

interface BuemThermalLoadProfile {
summary: {
heating: { total: BuemQuantity };
cooling?: { total: BuemQuantity };
electricity: { total: BuemQuantity };
peak_heating_load?: BuemQuantity;
peak_cooling_load?: BuemQuantity;
energy_intensity?: BuemQuantity;
};
timeseries?: {
unit: string;
timestamps: string[];
heating: number[];
cooling?: number[];
electricity: number[];
};
}

interface BuemBuildingResponse {
id: string;
buem: {
thermal_load_profile: BuemThermalLoadProfile;
model_metadata?: Record<string, unknown>;
};
}

export interface BuemThermalSummary {
heatingKwh: number;
coolingKwh: number;
electricityKwh: number;
peakHeatingKw: number;
peakCoolingKw: number;
energyIntensityKwhM2: number;
}

export interface BuemSimulationResult {
timeseries: LoadDataPoint[];
thermalSummary: BuemThermalSummary;
}

/**
* Runs BuEM for one building via buem-gateway's single-building endpoint
* (POST /buem/building — no topology wrapper, since this UI only ever has
* one building) and converts the result into the UI's LoadDataPoint /
* thermal summary shapes.
*
* Returns null on any failure — unreachable service, BuEM rejected the
* request (e.g. no TABULA match when envelope was incomplete), timeout —
* so callers can surface a clear message without crashing. The request can
* legitimately take several seconds: BuEM runs a real physics solve, not a
* lookup.
*/
export async function runBuildingSimulation(
identity: BuildingIdentity,
elements: Record<string, any>,
general: Record<string, any>,
modelId: string,
batteryConfig?: Record<string, any>,
): Promise<BuemSimulationResult | null> {
const feature = serializeToBuemFeature(
identity, elements, general,
undefined, undefined, undefined, undefined,
batteryConfig,
);

const request = {
id: feature.id,
geometry: feature.geometry,
model_id: modelId,
start_date: feature.properties.start_time,
end_date: feature.properties.end_time,
resolution: Number(feature.properties.resolution),
buem: feature.properties.buem,
};

try {
const res = await fetch(`${BASE_URL}/buem/building`, {
method: 'POST',
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: AbortSignal.timeout(60000),
});
if (!res.ok) return null;
const body = (await res.json()) as BuemBuildingResponse;
return toSimulationResult(body.buem.thermal_load_profile);
} catch {
return null;
}
}

function toSimulationResult(profile: BuemThermalLoadProfile): BuemSimulationResult {
const ts = profile.timeseries;
const timeseries: LoadDataPoint[] = ts
? ts.timestamps.map((timestamp, i) => ({
timestamp,
heating: ts.heating[i] ?? 0,
electricity: ts.electricity[i] ?? 0,
// LoadDataPoint has no cooling field. Reusing "hotwater" for BuEM's
// 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,
}))
Comment on lines +133 to +142
: [];

return {
timeseries,
thermalSummary: {
heatingKwh: profile.summary.heating.total.value,
coolingKwh: profile.summary.cooling?.total.value ?? 0,
electricityKwh: profile.summary.electricity.total.value,
peakHeatingKw: profile.summary.peak_heating_load?.value ?? 0,
peakCoolingKw: profile.summary.peak_cooling_load?.value ?? 0,
energyIntensityKwhM2: profile.summary.energy_intensity?.value ?? 0,
},
};
}
17 changes: 13 additions & 4 deletions src/app/lib/ignisApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import { ignisInputsFromTabulaData, toIgnisApiPayload } from './ignisAdapter';

const BASE_URL = (import.meta.env.VITE_IGNIS_API_URL as string | undefined) ?? 'http://localhost:8080';

/**
* Identifies this app to the reverse proxy sitting in front of ignis.
* Prototype-stage credential only — see the orchestration-layer decision
* note before this pattern is carried into production.
*/
const AUTH_HEADERS: Record<string, string> = import.meta.env.VITE_IGNIS_API_KEY
? { 'X-Api-Key': import.meta.env.VITE_IGNIS_API_KEY as string }
: {};

// ─── TABULA code mappings ─────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -95,7 +104,7 @@ export async function fetchMatchingVariants(
+ `?type=${encodeURIComponent(typeCode)}&period=${encodeURIComponent(periodCode)}`;

try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const res = await fetch(url, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
return (await res.json()) as IgnisMatchResponse;
} catch {
Expand All @@ -110,7 +119,7 @@ export async function fetchMatchingVariants(
export async function fetchVariantData(variantCode: string): Promise<IgnisDataResponse | null> {
const url = `${BASE_URL}/api/v1/data/${encodeURIComponent(variantCode)}`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const res = await fetch(url, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
if (!res.ok) return null;
return (await res.json()) as IgnisDataResponse;
} catch {
Expand Down Expand Up @@ -164,7 +173,7 @@ export async function loadVariantLevels(
export async function fetchFieldMetadata(): Promise<IgnisFieldMetadata[]> {
const url = `${BASE_URL}/api/v1/fields`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const res = await fetch(url, { headers: AUTH_HEADERS, signal: AbortSignal.timeout(5000) });
if (!res.ok) return [];
const body = (await res.json()) as IgnisFieldMetadataResponse;
return body.data ?? [];
Expand All @@ -187,7 +196,7 @@ export async function calculateHeatDemand(
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
body: payload ? JSON.stringify(payload) : undefined,
signal: AbortSignal.timeout(10000),
});
Expand Down
Loading