diff --git a/src/config/simulation.ts b/src/config/simulation.ts index cf4a7cb..5fdd57d 100644 --- a/src/config/simulation.ts +++ b/src/config/simulation.ts @@ -1,4 +1,6 @@ +import { Material } from '@/types/material'; import { TimeUnit } from '@/types/time'; +import { NonEmptyArray } from '@/types/utils'; export const SIMULATION_FRAME_MS = 150; export const SIMULATION_SLIDING_WINDOW = { window: 2, unit: TimeUnit.Days }; @@ -9,8 +11,29 @@ export const SIMULATION_CSV_FILE = { export const SIMULATION_INDOOR_TEMPERATURE_CELCIUS = 22; export const SIMULATION_PRICE_KWH = 0.22; -export const SIMULATION_DEFAULT_MATERIAL = { +export const AEROGEL_MATERIAL: Material = { price: 0, thermalConductivity: 0.021, thickness: 0.25, }; +export const SINGLE_WINDOW_PANE_MATERIAL: Material = { + price: 0, + thermalConductivity: 0.8, + thickness: 0.005, +}; +export const DOUBLE_WINDOW_PANE_MATERIAL: NonEmptyArray = [ + SINGLE_WINDOW_PANE_MATERIAL, + // Air + { + price: 0, + thermalConductivity: 0.024, + thickness: 0.005, + }, + SINGLE_WINDOW_PANE_MATERIAL, +]; + +export const SIMULATION_DEFAULT_WALL_MATERIALS: NonEmptyArray = [ + AEROGEL_MATERIAL, +]; +export const SIMULATION_DEFAULT_WINDOW_MATERIALS: NonEmptyArray = + DOUBLE_WINDOW_PANE_MATERIAL; diff --git a/src/context/HouseComponentsContext.tsx b/src/context/HouseComponentsContext.tsx new file mode 100644 index 0000000..e90634c --- /dev/null +++ b/src/context/HouseComponentsContext.tsx @@ -0,0 +1,114 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { + SIMULATION_DEFAULT_WALL_MATERIALS, + SIMULATION_DEFAULT_WINDOW_MATERIALS, +} from '@/config/simulation'; +import { HouseComponents } from '@/models/HouseComponents'; +import { HouseComponentType, Size } from '@/types/houseComponent'; +import { Material } from '@/types/material'; +import { NonEmptyArray } from '@/types/utils'; +import { undefinedContextErrorFactory } from '@/utils/context'; + +export type RegisterComponentParams = { + componentId: string; + parentId?: string; + size: Size; + componentType: HouseComponentType; +}; + +type HouseComponentsContextType = { + houseComponents: HouseComponents; + registerComponent: (params: RegisterComponentParams) => void; +}; + +const HouseComponentsContext = createContext( + null, +); + +type Props = { + children: ReactNode; +}; + +export const HouseComponentsProvider = ({ children }: Props): ReactNode => { + // TODO: will be updated by users + // An house component can be composed with multiple materials + // For example, a double-glazed window is made up of two panes of glass and air. + const componentMaterials: Map< + HouseComponentType, + NonEmptyArray + > = useMemo( + () => + new Map([ + // TODO: load form a CSV + [HouseComponentType.Wall, SIMULATION_DEFAULT_WALL_MATERIALS], + [HouseComponentType.Window, SIMULATION_DEFAULT_WINDOW_MATERIALS], + ]), + [], + ); + const [houseComponents, setHouseComponents] = useState(() => + HouseComponents.create(), + ); + + const registerComponent = useCallback( + ({ + componentId, + parentId, + size, + componentType, + }: RegisterComponentParams): void => { + const materials = componentMaterials.get(componentType); + + if (!materials?.length) { + throw new Error( + `No material was found for the component ${componentType}`, + ); + } + + setHouseComponents((curr) => + curr.cloneWith({ + componentId, + parentId, + component: { + size, + materials, + componentType, + actualArea: size.height * size.width, + }, + }), + ); + }, + [componentMaterials], + ); + + const contextValue = useMemo( + () => ({ + houseComponents, + registerComponent, + }), + [houseComponents, registerComponent], + ); + + return ( + + {children} + + ); +}; + +export const useHouseComponents = (): HouseComponentsContextType => { + const context = useContext(HouseComponentsContext); + + if (!context) { + throw undefinedContextErrorFactory('HouseComponents'); + } + + return context; +}; diff --git a/src/context/SimulationContext.tsx b/src/context/SimulationContext.tsx index fba947f..ae80441 100644 --- a/src/context/SimulationContext.tsx +++ b/src/context/SimulationContext.tsx @@ -10,22 +10,13 @@ import { } from 'react'; import { - SIMULATION_DEFAULT_MATERIAL, SIMULATION_INDOOR_TEMPERATURE_CELCIUS, SIMULATION_PRICE_KWH, } from '@/config/simulation'; import { useHeatLoss } from '@/hooks/useHeatLoss'; -import { - RegisterComponentParams, - useHouseComponents, -} from '@/hooks/useHouseComponents'; import { useSimulationProgression } from '@/hooks/useSimulationProgression'; import { FormattedHeatLoss } from '@/types/heatLoss'; -import { - HeatLossPerComponent, - HouseComponentType, -} from '@/types/houseComponent'; -import { Material } from '@/types/material'; +import { HeatLossPerComponent } from '@/types/houseComponent'; import { SimulationProgression, SimulationStatus } from '@/types/simulation'; import { SlidingWindow } from '@/types/temperatures'; import { FormattedTime, TimeUnit, TimeUnitType } from '@/types/time'; @@ -39,6 +30,7 @@ import { TemperatureIterator, initSlidingWindow, } from '../models/TemperatureIterator'; +import { useHouseComponents } from './HouseComponentsContext'; type SimulationContextType = { status: SimulationStatus; @@ -50,9 +42,7 @@ type SimulationContextType = { progression: SimulationProgression; period: SlidingWindow['period']; duration: FormattedTime; - materials: Map; startSimulation: () => void; - registerComponent: (params: RegisterComponentParams) => void; }; const SimulationContext = createContext(null); @@ -88,15 +78,10 @@ export const SimulationProvider = ({ // TODO: These parameters will be changed by the user const indoorTemperature = SIMULATION_INDOOR_TEMPERATURE_CELCIUS; - const materials: Map = useMemo( - () => new Map([[HouseComponentType.Wall, SIMULATION_DEFAULT_MATERIAL]]), - [], - ); + const pricekWh = SIMULATION_PRICE_KWH; - const { houseComponents, registerComponent } = useHouseComponents({ - materials, - }); + const { houseComponents } = useHouseComponents(); const { heatLosses, totalHeatLoss } = useHeatLoss({ houseComponents, @@ -158,7 +143,6 @@ export const SimulationProvider = ({ period: currentWindow.period, progression, duration: simulationDuration, - materials, status: simulationStatus, heatLosses, totalHeatLoss: formatHeatLossRate(totalHeatLoss), @@ -168,17 +152,14 @@ export const SimulationProvider = ({ totalHeatLoss / powerConversionFactors.KiloWatt, }), startSimulation, - registerComponent, }), [ currentWindow.mean, currentWindow.period, heatLosses, indoorTemperature, - materials, pricekWh, progression, - registerComponent, simulationDuration, simulationStatus, startSimulation, diff --git a/src/hooks/useHeatLoss.tsx b/src/hooks/useHeatLoss.tsx index bd65990..56fe1ff 100644 --- a/src/hooks/useHeatLoss.tsx +++ b/src/hooks/useHeatLoss.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; -import { HeatLossPerComponent, HouseComponents } from '@/types/houseComponent'; +import { HouseComponents } from '@/models/HouseComponents'; +import { HeatLossPerComponent } from '@/types/houseComponent'; import { TimeUnitType } from '@/types/time'; import { calculateHeatLossConstantFactor, @@ -32,13 +33,12 @@ export const useHeatLoss = ({ // Compute the constant factors per house's components const heatLossConstantFactors = useMemo( () => - Array.from(houseComponents.entries()).reduce( - (acc, [id, c]) => ({ + houseComponents.getAll().reduce( + (acc, c) => ({ ...acc, - [id]: calculateHeatLossConstantFactor({ - area: c.area, - thermalConductivity: c.material.thermalConductivity, - materialThickness: c.material.thickness, + [c.id]: calculateHeatLossConstantFactor({ + area: c.actualArea, + materials: c.materials, }), }), {}, diff --git a/src/hooks/useHouseComponents.tsx b/src/hooks/useHouseComponents.tsx deleted file mode 100644 index 25e8af7..0000000 --- a/src/hooks/useHouseComponents.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; - -import { Vector2 } from 'three'; - -import { HouseComponentType, HouseComponents } from '@/types/houseComponent'; -import { Material } from '@/types/material'; - -export type RegisterComponentParams = { - id: string; - size: Vector2; - componentType: HouseComponentType; -}; - -type UseHouseComponentsReturnType = { - houseComponents: HouseComponents; - registerComponent: (params: RegisterComponentParams) => void; -}; - -type Props = { - materials: Map; -}; - -export const useHouseComponents = ({ - materials, -}: Props): UseHouseComponentsReturnType => { - const houseComponentsRegister = useRef(new Map()); - const [houseComponents, setHouseComponents] = useState( - () => new Map(), - ); - - const registerComponent = useCallback( - ({ id, size, componentType }: RegisterComponentParams): void => { - const material = materials.get(componentType); - - if (!material) { - throw new Error( - `No material was found for the component ${componentType}`, - ); - } - - // The ref is used here to avoid concurrency of updating the state. - // Without the ref, if multiple components register at the same time, - // only the last call to registerComponent will be set in the state. - houseComponentsRegister.current.set(id, { - area: size.x * size.y, - material, - }); - setHouseComponents(houseComponentsRegister.current); - }, - [houseComponentsRegister, materials], - ); - - return { - houseComponents, - registerComponent, - }; -}; diff --git a/src/models/HouseComponents.test.ts b/src/models/HouseComponents.test.ts new file mode 100644 index 0000000..2efb935 --- /dev/null +++ b/src/models/HouseComponents.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest'; + +import { HouseComponent, HouseComponentType } from '@/types/houseComponent'; +import { Material } from '@/types/material'; + +import { HouseComponents } from './HouseComponents'; + +const WALL_MATERIAL_1: Material = { + thermalConductivity: 0.25, + thickness: 0.02, + price: 10, +}; + +const WALL_COMPONENT: HouseComponent = { + materials: [WALL_MATERIAL_1], + actualArea: 10, + componentType: HouseComponentType.Wall, + size: { width: 5, height: 2 }, +}; + +const WINDOW_MATERIAL: Material = { + thermalConductivity: 0.25, + thickness: 0.02, + price: 10, +}; + +const WINDOW_COMPONENT: HouseComponent = { + materials: [WINDOW_MATERIAL], + actualArea: 2, + componentType: HouseComponentType.Wall, + size: { width: 1, height: 2 }, +}; + +describe('HouseComponents', () => { + it('should create an empty instance using the static create method', () => { + const houseComponents = HouseComponents.create(); + expect(houseComponents.getAll().length).eq(0); + }); + + it('should add a new component and returns a new instance', () => { + const houseComponents = HouseComponents.create(); + const newHouseComponents = houseComponents.cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT, + }); + + expect(newHouseComponents.getAll().length).eq(1); + expect(newHouseComponents.get('wall1')).toEqual(WALL_COMPONENT); + + // Original instance should remain unchanged + expect(houseComponents.getAll().length).eq(0); + }); + + it('should add a child component', () => { + const houseComponents = HouseComponents.create().cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT, + }); + const newHouseComponents = houseComponents.cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT, + }); + + expect(newHouseComponents.getAll().length).eq(2); + expect(newHouseComponents.get('window1')).toEqual(WINDOW_COMPONENT); + expect(newHouseComponents.get('wall1').actualArea).eq( + WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, + ); + }); + + it('should get a component', () => { + const houseComponents = HouseComponents.create() + .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT, + }); + + const wall = houseComponents.get('wall1'); + expect(wall.actualArea).eq( + WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, + ); + }); + + it('should get all components', () => { + const houseComponents = HouseComponents.create() + .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT, + }); + + const allComponents = houseComponents.getAll(); + expect(allComponents).toEqual([ + { + id: 'wall1', + ...WALL_COMPONENT, + actualArea: WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, + }, + { id: 'window1', ...WINDOW_COMPONENT }, + ]); + }); + + it('should throw an error if a component is its own parent', () => { + const houseComponents = HouseComponents.create(); + expect(() => + houseComponents.cloneWith({ + parentId: 'comp1', + componentId: 'comp1', + component: WALL_COMPONENT, + }), + ).throw('A component cannot be its own parent!'); + }); + + it('should throw an error if a component is already assigned to a different parent', () => { + const houseComponents = HouseComponents.create() + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT, + }) + .cloneWith({ componentId: 'wall2', component: WINDOW_COMPONENT }); + + expect(() => + houseComponents.cloneWith({ + parentId: 'wall2', + componentId: 'window1', + component: WALL_COMPONENT, + }), + ).throw("The component 'window1' is already assigned to a parent."); + }); + + it('should throw an error if a component does not exist', () => { + expect(() => HouseComponents.create().get('nonExistent')).throw( + "The house component 'nonExistent' was not found!", + ); + }); + + it('should throw an error if actual area is incorrect after accounting for children', () => { + const houseComponents = HouseComponents.create() + .cloneWith({ + componentId: 'wall1', + component: { ...WALL_COMPONENT, actualArea: 2 }, + }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: { ...WINDOW_COMPONENT, actualArea: 3 }, + }); + + expect(() => houseComponents.get('wall1')).throw( + "The actual area of house component 'wall1' is incorrect!", + ); + }); +}); diff --git a/src/models/HouseComponents.ts b/src/models/HouseComponents.ts new file mode 100644 index 0000000..047dfbf --- /dev/null +++ b/src/models/HouseComponents.ts @@ -0,0 +1,135 @@ +import { HouseComponent } from '@/types/houseComponent'; + +type HouseComponentResult = HouseComponent & { id: string }; + +/** + * Manages a tree-like structure of house components, ensuring immutability for efficient React updates. + */ +export class HouseComponents { + /** + * A map storing all house components, keyed by their unique ID. + */ + private readonly components: Map = new Map(); + + /** + * A map storing the parent ID of each component. + * It is useful to know which wall the window is associated with. + */ + private readonly parentComponentIds: Map = new Map(); + + /** + * Private constructor; instances should be created using the `create()` factory method. + * This allows to abstract the internal structure of the Class and to faciliate the instantiation of immutable object. + * @param initialComponents An optional initial set of components. + * @param initialParentComponentIds An optional initial set of parent-child relationships. + */ + private constructor( + initialComponents?: Map, + initialParentComponentIds?: Map, + ) { + this.components = initialComponents || new Map(); + this.parentComponentIds = initialParentComponentIds || new Map(); + } + + public static create(): HouseComponents { + return new HouseComponents(); + } + + /** + * Creates a new `HouseComponents` instance with the given component added or updated. The original instance remains unchanged. This method is designed to support immutable updates in React applications. + * @param parentId The ID of the parent component, or `undefined` if it's a root component (like a wall). + * @param componentId The unique ID of the component. + * @param component The component object itself. + * @throws {Error} If `parentId` is the same as `componentId` (a component cannot be its own parent), or if the component is already assigned to a different parent. + * @returns A new `HouseComponents` instance with the component added or updated. + */ + public cloneWith({ + parentId, + componentId, + component, + }: { + parentId?: string; + componentId: string; + component: HouseComponent; + }): HouseComponents { + if (parentId === componentId) { + throw new Error('A component cannot be its own parent!'); + } + + const newComponents = new Map(this.components); + newComponents.set(componentId, component); + + const newParentComponentIds = new Map(this.parentComponentIds); + + const currentParentId = newParentComponentIds.get(componentId); + + if (currentParentId && currentParentId !== parentId) { + throw new Error( + `The component '${componentId}' is already assigned to a parent.`, + ); + } + + if (parentId) { + newParentComponentIds.set(componentId, parentId); + } else { + newParentComponentIds.delete(componentId); + } + + return new HouseComponents(newComponents, newParentComponentIds); + } + + /** + * Retrieves all child components of a given parent component. + * @param parentId The ID of the parent component. + * @returns An array of child components. Returns an empty array if no children are found or the parent doesn't exist. + */ + private getChildren(parentId: string): HouseComponent[] { + return Array.from(this.parentComponentIds.entries()) + .filter(([_, v]) => v === parentId) + .map(([k, _]) => this.components.get(k)) + .filter((c): c is HouseComponent => Boolean(c)); + } + + /** + * Retrieves a component and calculates its actual area by subtracting the area of its children like the windows for a wall. + * @param componentId The ID of the component to retrieve. + * @returns The component object with its actual area calculated. + * @throws Error if the component is not found or if its actual area is incorrect (less than or equal to zero after accounting for children). + */ + public get(componentId: string): HouseComponent { + const component = this.components.get(componentId); + + if (!component) { + throw new Error(`The house component '${componentId}' was not found!`); + } + + const children = this.getChildren(componentId); + const totalChildrenArea = children.reduce( + (acc, comp) => acc + comp.actualArea, + 0, + ); + const actualArea = component.actualArea - totalChildrenArea; + + if (actualArea <= 0) { + throw new Error( + `The actual area of house component '${componentId}' is incorrect!`, + ); + } + + return { + ...component, + actualArea, + }; + } + + /** + * Retrieves all components along with their IDs. + * @returns An array of all components, each with its ID. + */ + public getAll(): HouseComponentResult[] { + return Array.from(this.components.keys()).map((k) => ({ + id: k, + ...this.get(k), + })); + } +} diff --git a/src/modules/models/HeatLossArrow/HeatLossArrow.tsx b/src/modules/models/HeatLossArrow/HeatLossArrow.tsx index 22eed07..df03eff 100644 --- a/src/modules/models/HeatLossArrow/HeatLossArrow.tsx +++ b/src/modules/models/HeatLossArrow/HeatLossArrow.tsx @@ -38,7 +38,7 @@ type Props = JSX.IntrinsicElements['group'] & { const MIN_HEATLOSS = 0; const MAX_HEATLOSS = 5 * powerConversionFactors.KiloWatt; const MIN_SCALE = 0.8; -const MAX_SCALE = 1.6; +const MAX_SCALE = 1.2; const ARRAY_COLOR = new Color('red'); const TEXT_COLOR = 'white'; diff --git a/src/modules/models/House/ResidentialHouse/Wall.tsx b/src/modules/models/House/ResidentialHouse/Wall.tsx index 926bdfd..9edc28a 100644 --- a/src/modules/models/House/ResidentialHouse/Wall.tsx +++ b/src/modules/models/House/ResidentialHouse/Wall.tsx @@ -1,5 +1,6 @@ import { memo, useEffect } from 'react'; +import { useHouseComponents } from '@/context/HouseComponentsContext'; import { useSimulation } from '@/context/SimulationContext'; import { HouseComponentType } from '@/types/houseComponent'; import { WallProps } from '@/types/wall'; @@ -23,12 +24,13 @@ const WallComponent = ({ hasWindows?: boolean; wallProps: WallProps; }): JSX.Element => { - const { registerComponent, heatLosses } = useSimulation(); + const { heatLosses } = useSimulation(); + const { registerComponent } = useHouseComponents(); const heatLoss = heatLosses[id] ?? 0; useEffect(() => { registerComponent({ - id, + componentId: id, size: getComponentSize(nodes[wallProps.geometryKey].geometry), componentType: HouseComponentType.Wall, }); @@ -57,8 +59,10 @@ const WallComponent = ({ /> )} {hasWindows && - wallProps.windows.positions.map((pos) => ( + wallProps.windows.positions.map((pos, idx) => ( ( - - - - -); +}: Props): JSX.Element => { + const id = `${wallId}-Window-${windowIdx}`; + const { heatLosses } = useSimulation(); + const { registerComponent } = useHouseComponents(); + + const heatLoss = heatLosses[id] ?? 0; + + useEffect(() => { + registerComponent({ + parentId: wallId, + componentId: id, + size: getComponentSize(nodes.WindowFrame_2.geometry), + componentType: HouseComponentType.Window, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {/* Window Frame */} + + {/* Windows Glasses */} + + + + ); +}; diff --git a/src/modules/models/House/ResidentialHouse/useResidentialHouse.tsx b/src/modules/models/House/ResidentialHouse/useResidentialHouse.tsx index 2934112..2351623 100644 --- a/src/modules/models/House/ResidentialHouse/useResidentialHouse.tsx +++ b/src/modules/models/House/ResidentialHouse/useResidentialHouse.tsx @@ -1,14 +1,9 @@ import GLB_FILE_PATH from '@models/ResidentialHouse.glb?url'; import { useGLTF } from '@react-three/drei'; -import { - BufferGeometry, - Mesh, - MeshStandardMaterial, - Vector2, - Vector3, -} from 'three'; +import { BufferGeometry, Mesh, MeshStandardMaterial, Vector3 } from 'three'; import { GLTF } from 'three-stdlib'; +import { Size } from '@/types/houseComponent'; import { fromRGB } from '@/utils/colors'; const COLORS = { @@ -54,17 +49,18 @@ type UseResidentialHouse = { materials: GLTFResult['materials']; }; -export const getComponentSize = (geometry: BufferGeometry): Vector2 => { +export const getComponentSize = (geometry: BufferGeometry): Size => { const size = new Vector3(); geometry.boundingBox?.getSize(size); + const { x, y: height, z } = size; + // We only want the height and width of the component, not the thickness. - const [width, height] = size - .toArray() - .sort((a, b) => b - a) - .slice(0, 2); + // Because it depends on the axes, the thickness can be x or z. + // As the width is always greater than the thickness, we always take the larger size. + const width = Math.max(x, z); - return new Vector2(width, height); + return { width, height }; }; export const useResidentialHouse = (): UseResidentialHouse => { diff --git a/src/modules/scenes/FirstScene.tsx b/src/modules/scenes/FirstScene.tsx index 622244b..4510012 100644 --- a/src/modules/scenes/FirstScene.tsx +++ b/src/modules/scenes/FirstScene.tsx @@ -6,6 +6,7 @@ import { OrbitControls } from '@react-three/drei'; import { Canvas } from '@react-three/fiber'; import { SIMULATION_CSV_FILE, SIMULATION_FRAME_MS } from '@/config/simulation'; +import { HouseComponentsProvider } from '@/context/HouseComponentsContext'; import { SeasonProvider } from '@/context/SeasonContext'; import { SimulationProvider, useSimulation } from '@/context/SimulationContext'; import { SimulationStatus } from '@/types/simulation'; @@ -69,14 +70,16 @@ const FirstSceneComponent = (): JSX.Element => { }; const FirstScene = (): JSX.Element => ( - - - - - + + + + + + + ); export default FirstScene; diff --git a/src/types/houseComponent.ts b/src/types/houseComponent.ts index 2f21d53..feb01f6 100644 --- a/src/types/houseComponent.ts +++ b/src/types/houseComponent.ts @@ -1,4 +1,5 @@ import { Material } from './material'; +import { NonEmptyArray } from './utils'; export enum HouseComponentType { Wall = 'Wall', @@ -10,9 +11,19 @@ export type HeatLossPerComponent = { [componentId: string]: number; }; -type HouseComponent = { - material: Material; - area: number; +export type Size = { + width: number; + height: number; }; -export type HouseComponents = Map; +export type HouseComponent = { + materials: NonEmptyArray; + size: Size; + componentType: HouseComponentType; + /** + * The actual surface area, taking into account other elements. + * For example, for a wall, potential windows or doors must be taken into account, + * which means that the effective surface area is the surface area of the wall - the surface area of the windows. + * */ + actualArea: number; +}; diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000..0c6620a --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1 @@ +export type NonEmptyArray = readonly [T, ...T[]]; diff --git a/src/utils/heatLoss.test.ts b/src/utils/heatLoss.test.ts index f233685..9f0ae9c 100644 --- a/src/utils/heatLoss.test.ts +++ b/src/utils/heatLoss.test.ts @@ -13,60 +13,82 @@ import { timeConversionFactors } from './time'; describe('Heat Loss Utils', () => { describe('calculateHeatLossConstantFactor', () => { it('should calculate the correct heat loss constant factor', () => { - const thermalConductivity = 0.2; // W/m·K const area = 10; // m² + const thermalConductivity = 0.2; // W/m·K const materialThickness = 0.5; // m const expectedFactor = (thermalConductivity * area) / materialThickness; const result = calculateHeatLossConstantFactor({ - thermalConductivity, area, - materialThickness, + materials: [{ thickness: materialThickness, thermalConductivity }], + }); + + expect(result).toBe(expectedFactor); + }); + + it('should calculate the correct heat loss constant factor if multiple materials', () => { + // This is an example with a double pane window + const area = 2; + const airMaterial = { + thermalConductivity: 0.024, + thickness: 0.005, + }; + const windowMaterial = { + thermalConductivity: 0.8, + thickness: 0.005, + }; + + const expectedFactor = + area / + ((2 * windowMaterial.thickness) / windowMaterial.thermalConductivity + + airMaterial.thickness / airMaterial.thermalConductivity); + + const result = calculateHeatLossConstantFactor({ + area, + materials: [windowMaterial, airMaterial, windowMaterial], }); expect(result).toBe(expectedFactor); }); it('should throw an error if thermal conductivity is non-positive', () => { - const thermalConductivity = 0; const area = 10; + const thermalConductivity = 0; const materialThickness = 0.5; expect(() => calculateHeatLossConstantFactor({ - thermalConductivity, area, - materialThickness, + materials: [{ thickness: materialThickness, thermalConductivity }], }), ).toThrowError('The thermal conductivity (k) must be greater than 0.'); expect(() => calculateHeatLossConstantFactor({ - thermalConductivity: -1, area, - materialThickness, + materials: [ + { thickness: materialThickness, thermalConductivity: -1 }, + ], }), ).toThrowError('The thermal conductivity (k) must be greater than 0.'); }); it('should throw an error if material thickness is non-positive', () => { - const thermalConductivity = 0.2; const area = 10; + const thermalConductivity = 0.2; const materialThickness = 0; expect(() => calculateHeatLossConstantFactor({ - thermalConductivity, area, - materialThickness, + materials: [{ thickness: materialThickness, thermalConductivity }], }), ).toThrowError("The material's thickness (d) must be greater than 0."); expect(() => calculateHeatLossConstantFactor({ - thermalConductivity, area, - materialThickness: -1, + materials: [{ thickness: -1, thermalConductivity }], }), ).toThrowError("The material's thickness (d) must be greater than 0."); }); diff --git a/src/utils/heatLoss.ts b/src/utils/heatLoss.ts index c55b585..ac55edb 100644 --- a/src/utils/heatLoss.ts +++ b/src/utils/heatLoss.ts @@ -1,34 +1,45 @@ import { FormattedHeatLoss, HeatLossUnit } from '@/types/heatLoss'; +import { Material } from '@/types/material'; import { TimeUnitType } from '@/types/time'; +import { NonEmptyArray } from '@/types/utils'; import { timeConversionFactors } from './time'; /** - * Calculates the constant factor for the rate of heat loss through a solid material. + * Calculates the overall heat transfer coefficient (U-value) for a composite material. + * The U-value represents the rate of heat transfer through a unit area of a structure divided by the temperature difference across that structure. * - * @param thermalConductivity - Thermal conductivity of the material (W/m·K). - * @param area - Area through which heat is being lost (m²). - * @param materialThickness - Thickness of the material (m). - * @returns The heat loss constant factor (W/K). - * @throws Will throw an error if thermal conductivity or material thickness is non-positive. + * @param area - Surface area through which heat is transferred (m²). + * @param materials - Array of material objects, each with thermal conductivity and thickness. + * @returns The overall heat transfer coefficient (U-value) in W/m²K. + * @throws Will throw an error if any material has non-positive thermal conductivity or thickness. */ export const calculateHeatLossConstantFactor = ({ - thermalConductivity, area, - materialThickness, + materials, }: { - thermalConductivity: number; area: number; - materialThickness: number; + materials: NonEmptyArray>; }): number => { - if (thermalConductivity <= 0) { + if (materials.some((m) => m.thermalConductivity <= 0)) { throw new Error('The thermal conductivity (k) must be greater than 0.'); } - if (materialThickness <= 0) { + if (materials.some((m) => m.thickness <= 0)) { throw new Error("The material's thickness (d) must be greater than 0."); } - return (thermalConductivity * area) / materialThickness; + // Calculate the total thermal resistance (R-value) of the composite material. + const totalThermalResistance = materials.reduce( + (acc, material) => acc + material.thickness / material.thermalConductivity, + 0, + ); + + // Handle case where thermal resistance is zero to avoid division by zero. + if (totalThermalResistance === 0) { + return 0; + } + + return area / totalThermalResistance; }; /**