diff --git a/packages/backend/package.json b/packages/backend/package.json index b77507a..c45272a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,23 +1,24 @@ { - "name": "@ensol-test/backend", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "nodemon --exec ts-node -r tsconfig-paths/register src/index.ts" - }, - "dependencies": { - "axios": "^1.6.7", - "cors": "^2.8.5", - "express": "^4.18.2" - }, - "devDependencies": { - "@swc/core": "^1.4.8", - "@types/cors": "^2", - "@types/express": "^4.17.21", - "@types/node": "^20.12.2", - "nodemon": "^3.1.0", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.4.4" - } + "name": "@ensol-test/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "nodemon --exec ts-node -r tsconfig-paths/register src/index.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-validator": "^7.2.1" + }, + "devDependencies": { + "@swc/core": "^1.4.8", + "@types/cors": "^2", + "@types/express": "^4.17.21", + "@types/node": "^20.12.2", + "nodemon": "^3.1.0", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.4.4" + } } diff --git a/packages/backend/src/libs/PVGIS/index.ts b/packages/backend/src/libs/PVGIS/index.ts new file mode 100644 index 0000000..c763e17 --- /dev/null +++ b/packages/backend/src/libs/PVGIS/index.ts @@ -0,0 +1,69 @@ +import { + RoofInclination, + RoofOrientation, +} from "@ensol-test/types/simulations"; +import axios from "axios"; +import { PVGISApiPVResponse } from "./types"; + +const MAP_ORIENTATIONS_TO_JRC_API_ASPECT = { + S: 0, + W: 90, + SW: 45, + SE: -45, +}; + +export class PVGISApi { + static async getYearlyIrradienceByCoordinates( + latitude: number, + longitude: number, + roofOrientation: RoofOrientation, + roofInclination: RoofInclination, + ) { + try { + const baseUrl = "https://re.jrc.ec.europa.eu/api/v5_3/PVcalc"; + + const params = new URLSearchParams({ + lat: latitude.toString(), + lon: longitude.toString(), + loss: "14", + outputformat: "json", + peakpower: "1", + angle: roofInclination.toString(), + mountingplace: "building", + aspect: MAP_ORIENTATIONS_TO_JRC_API_ASPECT[ + roofOrientation + ].toString(), + }); + + const response = await axios.get( + `${baseUrl}?${params}`, + ); + + const yearlyIrradiance = + response.data.outputs.totals.fixed["H(i)_y"]; + + return yearlyIrradiance; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error( + "MRcalc API Error:", + error.response?.data || error.message, + ); + + if (error.response?.status === 400) { + throw new Error("Invalid coordinates or parameters"); + } else if (error.response?.status === 404) { + throw new Error( + "No radiation data available for these coordinates", + ); + } else if (error.response?.status === 429) { + throw new Error( + "Too many requests. Please try again later", + ); + } + } + console.log(error); + throw new Error("Failed to fetch solar irradiance data"); + } + } +} diff --git a/packages/backend/src/libs/PVGIS/types.ts b/packages/backend/src/libs/PVGIS/types.ts new file mode 100644 index 0000000..24eb23a --- /dev/null +++ b/packages/backend/src/libs/PVGIS/types.ts @@ -0,0 +1,9 @@ +export type PVGISApiPVResponse = { + outputs: { + totals: { + fixed: { + "H(i)_y": number; + }; + }; + }; +}; diff --git a/packages/backend/src/routes/helpers.ts b/packages/backend/src/routes/helpers.ts new file mode 100644 index 0000000..1a77aad --- /dev/null +++ b/packages/backend/src/routes/helpers.ts @@ -0,0 +1,36 @@ +import { RoofOrientation } from "@ensol-test/types/simulations"; +import { query, ValidationChain } from "express-validator"; + +export const validateSimulationParams: ValidationChain[] = [ + query("latitude") + .exists() + .withMessage("Latitude is required") + .isFloat({ min: -90, max: 90 }) + .withMessage("Latitude must be a number between -90 and 90"), + + query("longitude") + .exists() + .withMessage("Longitude is required") + .isFloat({ min: -180, max: 180 }) + .withMessage("Longitude must be a number between -180 and 180"), + + query("monthlyBill") + .exists() + .withMessage("Monthly bill is required") + .isInt({ min: 1 }) + .withMessage("Monthly bill must be a positive integer") + .toInt(), + + query("roofInclination") + .exists() + .withMessage("Roof inclination is required") + .isFloat({ min: 0, max: 90 }) + .withMessage("Roof inclination must be between 0° and 90°") + .toFloat(), + + query("roofOrientation") + .exists() + .withMessage("Roof orientation is required") + .isIn(Object.values(RoofOrientation)) + .withMessage("Roof orientation must be one of: W, SW, S, SE"), +]; diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 02d359f..2dffe0a 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -1,17 +1,47 @@ -import { asyncErrorMiddleware } from '@ensol-test/backend/middlewares/asyncErrorMiddleware'; -import { SimulationParameters, SimulationResponse } from '@ensol-test/types/simulations'; +import { asyncErrorMiddleware } from "@ensol-test/backend/middlewares/asyncErrorMiddleware"; +import { + RoofOrientation, + SimulationParameters, + SimulationResponse, +} from "@ensol-test/types/simulations"; -import express from 'express'; +import express from "express"; + +import { ValidationError, validationResult } from "express-validator"; +import { validateSimulationParams } from "./helpers"; +import { SimulationParametersQuery } from "../simulation/simulation.types"; +import { SimulationBusiness } from "../simulation/simulation.business"; const router = express.Router(); router.get( - '/simulations', - asyncErrorMiddleware(async (req, res) => { - //... + "/simulations", + validateSimulationParams, + asyncErrorMiddleware< + {}, + SimulationResponse | { errors: ValidationError[] }, + {}, + SimulationParametersQuery + >(async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const params: SimulationParameters = { + latitude: parseFloat(req.query.latitude), + longitude: parseFloat(req.query.longitude), + monthlyBill: parseInt(req.query.monthlyBill, 10), + roofInclination: parseFloat(req.query.roofInclination), + roofOrientation: req.query.roofOrientation as RoofOrientation, + }; + + const simulation = + await SimulationBusiness.getSolarSystemSimulation(params); - res.json({}); - }), + res.json({ + ...simulation, + }); + }), ); export { router }; diff --git a/packages/backend/src/simulation/simulation.business.ts b/packages/backend/src/simulation/simulation.business.ts new file mode 100644 index 0000000..32013df --- /dev/null +++ b/packages/backend/src/simulation/simulation.business.ts @@ -0,0 +1,58 @@ +import { + SimulationParameters, + SimulationResponse, +} from "@ensol-test/types/simulations"; +import { SimulationService } from "./simulation.service"; +import { PVGISApi } from "../libs/PVGIS"; + +export class SimulationBusiness { + static async getSolarSystemSimulation( + simulation: SimulationParameters, + ): Promise { + const { + latitude, + longitude, + monthlyBill, + roofInclination, + roofOrientation, + } = simulation; + + const yearlyElectricityConsumption = + SimulationService.calculateYearlyElectricityConsumption( + monthlyBill, + ); + const yearlyIrradience = + await PVGISApi.getYearlyIrradienceByCoordinates( + latitude, + longitude, + roofOrientation, + roofInclination, + ); + + const correctedYearlyIrradience = + SimulationService.calculateYearlyIrradienceWithAzimuthAndInclination( + yearlyIrradience, + roofOrientation, + roofInclination, + ); + const yearlyProductionPerCapacity = + SimulationService.calculateYearlyProductionPerCapacity( + correctedYearlyIrradience, + ); + + const yearlyEnergyProduction = + SimulationService.calculateYearlyProduction( + yearlyProductionPerCapacity, + ); + + const estimatedPanels = SimulationService.calculateEstimatedPanels( + yearlyElectricityConsumption, + yearlyProductionPerCapacity, + ); + + return { + estimatedPanels, + yearlyEnergyProduction, + }; + } +} diff --git a/packages/backend/src/simulation/simulation.constants.ts b/packages/backend/src/simulation/simulation.constants.ts new file mode 100644 index 0000000..544c41d --- /dev/null +++ b/packages/backend/src/simulation/simulation.constants.ts @@ -0,0 +1,71 @@ +import { + RoofInclination, + RoofOrientation, +} from "@ensol-test/types/simulations"; + +export const ELECTRICITY_COST_PER_KWH_FRANCE_2024 = 0.22; +export const GLOBAL_SYSTEM_EFFICIENCY = 0.8; + +export const PANELS_SOLARS = { + "DualSun Flash 425 TopCon": { + powerAreaRatio: 0.22, + efficiency: 0.21, + power: 425, + productionCapacity: 2.98, + }, +}; + +export const EFFICIENCY_LOSS_PER_ORIENTATION: { + [key in RoofOrientation]: { + [key in RoofInclination]: number; + }; +} = { + [RoofOrientation.W]: { + [RoofInclination.None]: 0.2, + [RoofInclination.Ten]: 0.18, + [RoofInclination.Twenty]: 0.16, + [RoofInclination.Thirty]: 0.15, + [RoofInclination.Forty]: 0.16, + [RoofInclination.Fifty]: 0.18, + [RoofInclination.Sixty]: 0.2, + [RoofInclination.Seventy]: 0.25, + [RoofInclination.Eighty]: 0.3, + [RoofInclination.Ninety]: 0.35, + }, + [RoofOrientation.SW]: { + [RoofInclination.None]: 0.15, + [RoofInclination.Ten]: 0.12, + [RoofInclination.Twenty]: 0.1, + [RoofInclination.Thirty]: 0.08, + [RoofInclination.Forty]: 0.1, + [RoofInclination.Fifty]: 0.12, + [RoofInclination.Sixty]: 0.15, + [RoofInclination.Seventy]: 0.2, + [RoofInclination.Eighty]: 0.25, + [RoofInclination.Ninety]: 0.3, + }, + [RoofOrientation.S]: { + [RoofInclination.None]: 0.12, + [RoofInclination.Ten]: 0.08, + [RoofInclination.Twenty]: 0.05, + [RoofInclination.Thirty]: 0.0, // Optimal + [RoofInclination.Forty]: 0.03, + [RoofInclination.Fifty]: 0.06, + [RoofInclination.Sixty]: 0.1, + [RoofInclination.Seventy]: 0.15, + [RoofInclination.Eighty]: 0.2, + [RoofInclination.Ninety]: 0.25, + }, + [RoofOrientation.SE]: { + [RoofInclination.None]: 0.15, + [RoofInclination.Ten]: 0.12, + [RoofInclination.Twenty]: 0.1, + [RoofInclination.Thirty]: 0.08, + [RoofInclination.Forty]: 0.1, + [RoofInclination.Fifty]: 0.12, + [RoofInclination.Sixty]: 0.15, + [RoofInclination.Seventy]: 0.2, + [RoofInclination.Eighty]: 0.25, + [RoofInclination.Ninety]: 0.3, + }, +}; diff --git a/packages/backend/src/simulation/simulation.service.ts b/packages/backend/src/simulation/simulation.service.ts new file mode 100644 index 0000000..3338a70 --- /dev/null +++ b/packages/backend/src/simulation/simulation.service.ts @@ -0,0 +1,89 @@ +import { + RoofInclination, + RoofOrientation, +} from "@ensol-test/types/simulations"; +import { + EFFICIENCY_LOSS_PER_ORIENTATION, + ELECTRICITY_COST_PER_KWH_FRANCE_2024, + GLOBAL_SYSTEM_EFFICIENCY, + PANELS_SOLARS, +} from "./simulation.constants"; + +export class SimulationService { + /** + * Calculates the yearly electricity consumption based on the given monthly bill + * @param {number} monthlyBill The monthly bill of the customer + * @returns {number} The yearly electricity consumption of the customer in kWh/an + */ + + static calculateYearlyElectricityConsumption(monthlyBill: number) { + return (monthlyBill / ELECTRICITY_COST_PER_KWH_FRANCE_2024) * 12; + } + + /** + * Calculates the yearly irradiance taking into account the loss of efficiency + * depending on the roof's orientation and inclination + * @param {number} yearlyIrradience The yearly irradiance given by the PVGIS API in kWh/m2/year + * @param {RoofOrientation} roofOrientation The orientation of the roof + * @param {RoofInclination} roofInclination The inclination of the roof + * @returns {number} The yearly irradiance taking into account the loss of efficiency in kWh/m2/year + */ + static calculateYearlyIrradienceWithAzimuthAndInclination( + yearlyIrradience: number, + roofOrientation: RoofOrientation, + roofInclination: RoofInclination, + ) { + return ( + yearlyIrradience * + (1 - + EFFICIENCY_LOSS_PER_ORIENTATION[roofOrientation][ + roofInclination + ]) + ); + } + + /** + * Calculates the yearly production per capacity in kWh/kWc/year + * @param {number} yearlyIrradience The yearly irradiance taking into account the loss of efficiency in kWh/m2/year + * @returns {number} The yearly production per capacity in kWh/kWc/year + */ + static calculateYearlyProductionPerCapacity(yearlyIrradience: number) { + const selectedPanel = PANELS_SOLARS["DualSun Flash 425 TopCon"]; + return ( + (yearlyIrradience * + selectedPanel.efficiency * + GLOBAL_SYSTEM_EFFICIENCY) / + selectedPanel.powerAreaRatio + ); + } + + /** + * Calculates the yearly production based on the given capacity + * @param {number} capacity The capacity of the solar panel installation in kWc + * @returns {number} The yearly production of the solar panel installation in kWh/year + */ + static calculateYearlyProduction(capacity: number) { + const selectedPanel = PANELS_SOLARS["DualSun Flash 425 TopCon"]; + return capacity * selectedPanel.productionCapacity; + } + + /** + * Calculates the estimated number of panels needed for a given yearly electricity consumption + * @param {number} yearlyElectricityConsumption The yearly electricity consumption of the customer in kWh/year + * @param {number} yearlyProductionPerCapacity The yearly production per capacity in kWh/kWc/year + * @returns {number} The estimated number of panels needed + */ + static calculateEstimatedPanels( + yearlyElectricityConsumption: number, + yearlyProductionPerCapacity: number, + ) { + const estimatedInstallationSize = Math.floor( + yearlyElectricityConsumption / yearlyProductionPerCapacity, + ); + return Math.round( + (estimatedInstallationSize / + PANELS_SOLARS["DualSun Flash 425 TopCon"].power) * + 1000, + ); + } +} diff --git a/packages/backend/src/simulation/simulation.types.ts b/packages/backend/src/simulation/simulation.types.ts new file mode 100644 index 0000000..d5eb3ad --- /dev/null +++ b/packages/backend/src/simulation/simulation.types.ts @@ -0,0 +1,7 @@ +export type SimulationParametersQuery = { + latitude: string; + longitude: string; + monthlyBill: string; + roofInclination: string; + roofOrientation: string; +}; diff --git a/packages/frontend/src/components/Form.tsx b/packages/frontend/src/components/Form.tsx index 5c14db3..4752e06 100644 --- a/packages/frontend/src/components/Form.tsx +++ b/packages/frontend/src/components/Form.tsx @@ -1,18 +1,145 @@ -import { getSimulation } from '@ensol-test/frontend/queries/simulation'; -import { SimulationResponse } from '@ensol-test/types/simulations'; -import { Button, Card, Stack } from '@mantine/core'; +import { getSimulation } from "@ensol-test/frontend/queries/simulation"; +import { + RoofInclination, + SimulationParameters, + SimulationResponse, +} from "@ensol-test/types/simulations"; +import { + Button, + Card, + Group, + NumberInput, + Select, + Stack, + TextInput, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; type Props = { - onSubmit: (results: SimulationResponse) => void; + onSubmit: (results: SimulationResponse) => void; +}; + +type SimulationForm = { + latitude: string; + longitude: string; + monthlyBill: number; + roofInclination: number; + roofOrientation: string; }; export const Form = ({ onSubmit }: Props) => { - return ( - - Le formulaire doit être ici - - - ); + const form = useForm({ + initialValues: { + latitude: "", + longitude: "", + roofInclination: 0, + monthlyBill: 0, + roofOrientation: "", + }, + validate: { + latitude: (value) => { + if (!value) return "Latitude is required"; + const num = parseFloat(value); + if (isNaN(num)) return "Must be a valid number"; + if (num < -90 || num > 90) return "Must be between -90 and 90"; + return null; + }, + longitude: (value) => { + if (!value) return "Longitude is required"; + const num = parseFloat(value); + if (isNaN(num)) return "Must be a valid number"; + if (num < -180 || num > 180) + return "Must be between -180 and 180"; + return null; + }, + roofInclination: (value) => { + if (value === null || value === undefined) + return "Inclination is required"; + if (!Object.values(RoofInclination).includes(value)) + return "Invalid inclination value"; + return null; + }, + monthlyBill: (value) => { + if (value === null || value === undefined) + return "Monthly bill is required"; + if (value <= 0) return "Must be a positive number"; + return null; + }, + roofOrientation: (value) => { + if (!value) return "Orientation is required"; + if (!["W", "SW", "S", "SE"].includes(value)) + return "Must be one of: W, SW, S, SE"; + return null; + }, + }, + }); + + const handleSubmit = async (values: SimulationForm) => { + const results = await getSimulation({ + latitude: parseFloat(values.latitude), + longitude: parseFloat(values.longitude), + monthlyBill: values.monthlyBill, + roofInclination: values.roofInclination, + roofOrientation: + values.roofOrientation as SimulationParameters["roofOrientation"], + }); + onSubmit(results); + }; + return ( + + +
+ + + + + + + +