@enerlence/suntropy-cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1986,7 +1986,7 @@ Example: suntropy studies set data --file study.json --data '{"referenceId":"REF
1986
1986
  outputError(new Error("No energy prices. Use: studies set prices"));
1987
1987
  return;
1988
1988
  }
1989
- const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class");
1989
+ const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class.js");
1990
1990
  const consCurve = new PowerCurve(consumption.days, false, consumption.identifier || "consumption", false);
1991
1991
  let totalProdCurve = null;
1992
1992
  for (const surf of surfaces) {
@@ -2116,6 +2116,312 @@ Example: suntropy studies set data --file study.json --data '{"referenceId":"REF
2116
2116
  outputError(handleApiError(err));
2117
2117
  }
2118
2118
  });
2119
+ studies.command("optimize-peakpower").description(
2120
+ "Optimize peak power based on consumption.\n\nSupports two modes:\n - Panel mode (default or when solarPanel is set): iterates peak power\n - Kit mode (when solarKit is set or --use-kits): evaluates available kits\n\nOptimization criteria (pick one):\n --energy-savings <pct> Target energy savings percentage\n --raw-consumption <pct> Production as percentage of consumption\n --max-excesses <pct> Max excesses as percentage of production\n --max-overproduction-months <n> Max months with overproduction\n\nSurface constraints:\n If surfaces have area + panel dimensions, max panels per surface is calculated.\n Without area, no surface constraint is applied (unlimited space).\n\nExamples:\n suntropy studies optimize-peakpower --file study.json --energy-savings 70\n suntropy studies optimize-peakpower --file study.json --raw-consumption 100 --use-kits\n suntropy studies optimize-peakpower --file study.json --max-excesses 15"
2121
+ ).option("--file <path>", "Study file path").option("--energy-savings <pct>", "Target energy savings %").option("--raw-consumption <pct>", "Target production as % of consumption").option("--max-excesses <pct>", "Max excesses as % of production").option("--max-overproduction-months <n>", "Max months with overproduction").option("--use-kits", "Force kit mode (fetch all active kits and select optimal)").option("--apply", "Apply the result to the study file (set peakpower/kit, recalculate production)").action(async (opts) => {
2122
+ try {
2123
+ const global = getGlobalOpts6(studies);
2124
+ const filePath = resolveFile(opts);
2125
+ const study = readStudy(filePath);
2126
+ const evaluationMode = {};
2127
+ if (opts.energySavings !== void 0) evaluationMode.energyPercentageSavings = parseFloat(opts.energySavings);
2128
+ else if (opts.rawConsumption !== void 0) evaluationMode.rawConsumptionPercentage = parseFloat(opts.rawConsumption);
2129
+ else if (opts.maxExcesses !== void 0) evaluationMode.maxExcessesPercentage = parseFloat(opts.maxExcesses);
2130
+ else if (opts.maxOverproductionMonths !== void 0) evaluationMode.maxNumberOfOverproductionMonths = parseInt(opts.maxOverproductionMonths);
2131
+ else {
2132
+ evaluationMode.rawConsumptionPercentage = 100;
2133
+ }
2134
+ const surfaces = study.surfaces;
2135
+ if (!surfaces?.length) {
2136
+ outputError(new Error("No surfaces in study. Use: studies add surface"));
2137
+ return;
2138
+ }
2139
+ const consumptionData = study.consumption;
2140
+ if (!consumptionData) {
2141
+ outputError(new Error("No consumption in study. Use: studies set consumption"));
2142
+ return;
2143
+ }
2144
+ const surfacesWithoutProduction = surfaces.filter((s) => !s.production);
2145
+ if (surfacesWithoutProduction.length > 0) {
2146
+ outputError(new Error(`${surfacesWithoutProduction.length} surface(s) without production. Use: studies calculate production --all-surfaces`));
2147
+ return;
2148
+ }
2149
+ const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class.js");
2150
+ const cd = consumptionData;
2151
+ const consumptionCurve = new PowerCurve(cd.days || cd, cd.ignore0 ?? false, cd.identifier || "consumption", cd.parseDate ?? false);
2152
+ const totalConsumption = consumptionCurve.getTotalAcumulate();
2153
+ const baseProduction = {};
2154
+ for (const surface of surfaces) {
2155
+ const sid = surface.surfaceId;
2156
+ const prodData = surface.production;
2157
+ const prodCurve = new PowerCurve(prodData.days || prodData, false, sid, false);
2158
+ const installedPower = surface.installedPower || 5e3;
2159
+ baseProduction[sid] = prodCurve.applyMultiplier(1 / (installedPower / 1e3));
2160
+ }
2161
+ const useKits = opts.useKits || study.peakPowerIntroductionMode === "solarKit";
2162
+ const solarClient = createServiceClient("solar", global);
2163
+ if (useKits) {
2164
+ const phaseNumber = study.phaseNumber || "single_phase";
2165
+ const kitsRes = await solarClient.get("/solar-kits", { params: { limit: 500 } });
2166
+ let allKits = Array.isArray(kitsRes.data) && Array.isArray(kitsRes.data[0]) ? kitsRes.data[0] : Array.isArray(kitsRes.data) ? kitsRes.data : kitsRes.data?.data || [];
2167
+ allKits = allKits.filter((k) => k.phaseNumber === phaseNumber && k.active !== false);
2168
+ const isCoplanar = surfaces.every((s) => !s.panelInclination);
2169
+ allKits = allKits.filter(
2170
+ (k) => isCoplanar === void 0 ? true : isCoplanar === (k.coplanar === null ? true : k.coplanar)
2171
+ );
2172
+ allKits.sort(
2173
+ (a, b) => a.panelNumber * (a.kitSolarPanel?.peakPower || 0) - b.panelNumber * (b.kitSolarPanel?.peakPower || 0)
2174
+ );
2175
+ if (allKits.length === 0) {
2176
+ outputError(new Error("No compatible kits found for current phase/surface configuration"));
2177
+ return;
2178
+ }
2179
+ let selectedKit = null;
2180
+ let bestApproxKit = null;
2181
+ let bestApproxValue = -Infinity;
2182
+ let firstValidKit = null;
2183
+ const optimizationLog = {};
2184
+ for (const kit of allKits) {
2185
+ const kitPanel = kit.kitSolarPanel;
2186
+ if (!kitPanel) continue;
2187
+ let remainingPanels = kit.panelNumber || 0;
2188
+ const surfPanelCount = {};
2189
+ let fits = true;
2190
+ for (const surface of surfaces) {
2191
+ const sid = surface.surfaceId;
2192
+ const area = surface.area;
2193
+ let maxPanels;
2194
+ if (area && kitPanel.width && kitPanel.heigth) {
2195
+ maxPanels = calculateMaxPanels(
2196
+ area,
2197
+ kitPanel,
2198
+ surface.availableAreaPercentage || 85,
2199
+ surface.panelInclination || 0,
2200
+ surface.inclination || 0,
2201
+ surface.panelPosition || "vertical",
2202
+ surface.polygonPath?.[0]?.lat || 37
2203
+ );
2204
+ } else {
2205
+ maxPanels = remainingPanels;
2206
+ }
2207
+ const assigned = Math.min(remainingPanels, maxPanels);
2208
+ surfPanelCount[sid] = assigned;
2209
+ remainingPanels -= assigned;
2210
+ }
2211
+ if (remainingPanels > 0) continue;
2212
+ if (!firstValidKit) firstValidKit = kit;
2213
+ let totalProd = null;
2214
+ for (const surface of surfaces) {
2215
+ const sid = surface.surfaceId;
2216
+ const panelCount = surfPanelCount[sid] || 0;
2217
+ const surfPeakPower = panelCount * kitPanel.peakPower / 1e3;
2218
+ const surfProd = baseProduction[sid].applyMultiplier(surfPeakPower);
2219
+ totalProd = totalProd ? totalProd.aggregatePowerCurve(surfProd) : surfProd;
2220
+ }
2221
+ const netConsumption = consumptionCurve.aggregatePowerCurve(totalProd.applyMultiplier(-1));
2222
+ const kitPeakPower = (kit.panelNumber || 0) * kitPanel.peakPower / 1e3;
2223
+ const totalProdKwh = totalProd.getTotalAcumulate();
2224
+ let criterionValue;
2225
+ let criterionMet = false;
2226
+ if (evaluationMode.energyPercentageSavings !== void 0) {
2227
+ const netFiltered = netConsumption.filterNegativeValues().getTotalAcumulate();
2228
+ criterionValue = (totalConsumption - netFiltered) / totalConsumption * 100;
2229
+ criterionMet = criterionValue >= evaluationMode.energyPercentageSavings;
2230
+ } else if (evaluationMode.rawConsumptionPercentage !== void 0) {
2231
+ criterionValue = totalProdKwh / totalConsumption * 100;
2232
+ criterionMet = criterionValue >= evaluationMode.rawConsumptionPercentage;
2233
+ } else if (evaluationMode.maxExcessesPercentage !== void 0) {
2234
+ const excessesCurve = netConsumption.filterPositiveValues();
2235
+ criterionValue = excessesCurve.getTotalAcumulate() / totalProdKwh * 100 * -1;
2236
+ criterionMet = criterionValue <= evaluationMode.maxExcessesPercentage;
2237
+ } else if (evaluationMode.maxNumberOfOverproductionMonths !== void 0) {
2238
+ const stats = netConsumption.calculateStatistics().statistics.anualMonthAccumulate;
2239
+ let byMonth = stats[Object.keys(stats).find((k) => k !== "definition") || ""] || {};
2240
+ delete byMonth.yearly;
2241
+ criterionValue = Object.values(byMonth).filter((v) => v < 0).length;
2242
+ criterionMet = criterionValue <= evaluationMode.maxNumberOfOverproductionMonths;
2243
+ } else {
2244
+ criterionValue = 0;
2245
+ }
2246
+ optimizationLog[kit.idSolarKit] = {
2247
+ identifier: kit.identifier,
2248
+ peakPower: kitPeakPower,
2249
+ totalProduction: totalProdKwh,
2250
+ criterionValue,
2251
+ criterionMet,
2252
+ panelDistribution: surfPanelCount
2253
+ };
2254
+ if (criterionValue > bestApproxValue) {
2255
+ bestApproxValue = criterionValue;
2256
+ bestApproxKit = kit;
2257
+ }
2258
+ if (criterionMet) {
2259
+ selectedKit = kit;
2260
+ break;
2261
+ }
2262
+ }
2263
+ if (!selectedKit) {
2264
+ selectedKit = bestApproxKit || firstValidKit;
2265
+ }
2266
+ const resultData = {
2267
+ mode: "kit",
2268
+ evaluationMode,
2269
+ selectedKit: selectedKit ? {
2270
+ idSolarKit: selectedKit.idSolarKit,
2271
+ identifier: selectedKit.identifier,
2272
+ peakPower: (selectedKit.panelNumber || 0) * (selectedKit.kitSolarPanel?.peakPower || 0) / 1e3,
2273
+ panelNumber: selectedKit.panelNumber,
2274
+ price: selectedKit.price
2275
+ } : null,
2276
+ kitsEvaluated: Object.keys(optimizationLog).length,
2277
+ optimizationLog
2278
+ };
2279
+ if (opts.apply && selectedKit) {
2280
+ updateStudy(filePath, (s) => {
2281
+ s.solarKit = selectedKit;
2282
+ s.peakPowerIntroductionMode = "solarKit";
2283
+ s.solarPanel = void 0;
2284
+ s.solarInverters = void 0;
2285
+ s.peakPowerOptimizationMethod = evaluationMode;
2286
+ return void 0;
2287
+ });
2288
+ resultData.applied = true;
2289
+ }
2290
+ output(resultData, global);
2291
+ } else {
2292
+ const panel = study.solarPanel;
2293
+ if (!panel) {
2294
+ outputError(new Error("No solar panel set. Use: studies set panel --panel-id <id>"));
2295
+ return;
2296
+ }
2297
+ const panelPeakPower = panel.peakPower || 450;
2298
+ const panelWidth = panel.width || 1134;
2299
+ const panelHeight = panel.heigth || panel.height || 1762;
2300
+ const panelObj = { peakPower: panelPeakPower, width: panelWidth, heigth: panelHeight };
2301
+ const surfacesMaxPeakPower = {};
2302
+ let totalMaxPeakPower = 0;
2303
+ for (const surface of surfaces) {
2304
+ const sid = surface.surfaceId;
2305
+ const area = surface.area;
2306
+ if (area && panelWidth && panelHeight) {
2307
+ const maxPanels = calculateMaxPanels(
2308
+ area,
2309
+ panelObj,
2310
+ surface.availableAreaPercentage || 85,
2311
+ surface.panelInclination || 0,
2312
+ surface.inclination || 0,
2313
+ surface.panelPosition || "vertical",
2314
+ surface.polygonPath?.[0]?.lat || 37
2315
+ );
2316
+ surfacesMaxPeakPower[sid] = maxPanels * panelPeakPower / 1e3;
2317
+ } else {
2318
+ surfacesMaxPeakPower[sid] = 500;
2319
+ }
2320
+ totalMaxPeakPower += surfacesMaxPeakPower[sid];
2321
+ }
2322
+ const isAscending = evaluationMode.energyPercentageSavings !== void 0 || evaluationMode.economicPercentageSavings !== void 0 || evaluationMode.rawConsumptionPercentage !== void 0;
2323
+ let peakPower = isAscending ? panelPeakPower / 1e3 : totalMaxPeakPower;
2324
+ const optimizationLog = {};
2325
+ let resultPeakPower = {};
2326
+ let lastResult;
2327
+ const MAX_ITERATIONS = 200;
2328
+ const TOLERANCE = 5e-3;
2329
+ let correctionFactor = 1;
2330
+ let itersSinceCorrection = 0;
2331
+ for (let iter = 0; iter < MAX_ITERATIONS && peakPower > 0 && peakPower <= totalMaxPeakPower; iter++) {
2332
+ peakPower = parseFloat(peakPower.toFixed(2));
2333
+ itersSinceCorrection++;
2334
+ if (itersSinceCorrection >= 20) {
2335
+ correctionFactor += 1.1;
2336
+ itersSinceCorrection = 0;
2337
+ }
2338
+ let changeRate;
2339
+ if (peakPower < 20) changeRate = panelPeakPower / 1e3 * correctionFactor;
2340
+ else if (peakPower < 500) changeRate = 5 * correctionFactor;
2341
+ else changeRate = 50 * correctionFactor;
2342
+ let remaining = peakPower;
2343
+ let totalProd = null;
2344
+ resultPeakPower = {};
2345
+ for (const surface of surfaces) {
2346
+ const sid = surface.surfaceId;
2347
+ const surfPP = Math.min(remaining, surfacesMaxPeakPower[sid]);
2348
+ remaining -= surfPP;
2349
+ resultPeakPower[sid] = surfPP;
2350
+ const surfProd = baseProduction[sid].applyMultiplier(surfPP);
2351
+ totalProd = totalProd ? totalProd.aggregatePowerCurve(surfProd) : surfProd;
2352
+ }
2353
+ const netConsumption = consumptionCurve.aggregatePowerCurve(totalProd.applyMultiplier(-1));
2354
+ const totalProdKwh = totalProd.getTotalAcumulate();
2355
+ let criterionValue;
2356
+ let criterionMet = false;
2357
+ if (evaluationMode.energyPercentageSavings !== void 0) {
2358
+ const netFiltered = netConsumption.filterNegativeValues().getTotalAcumulate();
2359
+ criterionValue = (totalConsumption - netFiltered) / totalConsumption * 100;
2360
+ criterionMet = criterionValue >= evaluationMode.energyPercentageSavings;
2361
+ } else if (evaluationMode.rawConsumptionPercentage !== void 0) {
2362
+ criterionValue = totalProdKwh / totalConsumption * 100;
2363
+ criterionMet = criterionValue >= evaluationMode.rawConsumptionPercentage;
2364
+ } else if (evaluationMode.maxExcessesPercentage !== void 0) {
2365
+ const excessesCurve = netConsumption.filterPositiveValues();
2366
+ criterionValue = excessesCurve.getTotalAcumulate() / totalProdKwh * 100 * -1;
2367
+ criterionMet = criterionValue <= evaluationMode.maxExcessesPercentage;
2368
+ } else if (evaluationMode.maxNumberOfOverproductionMonths !== void 0) {
2369
+ const stats = netConsumption.calculateStatistics().statistics.anualMonthAccumulate;
2370
+ let byMonth = stats[Object.keys(stats).find((k) => k !== "definition") || ""] || {};
2371
+ delete byMonth.yearly;
2372
+ criterionValue = Object.values(byMonth).filter((v) => v < 0).length;
2373
+ criterionMet = criterionValue <= evaluationMode.maxNumberOfOverproductionMonths;
2374
+ } else {
2375
+ criterionValue = 0;
2376
+ }
2377
+ optimizationLog[peakPower] = {
2378
+ totalProduction: totalProdKwh,
2379
+ criterionValue,
2380
+ criterionMet,
2381
+ surfacesPeakpower: { ...resultPeakPower }
2382
+ };
2383
+ if (criterionMet) break;
2384
+ if (isAscending && lastResult !== void 0 && lastResult * (1 + TOLERANCE) >= criterionValue) {
2385
+ break;
2386
+ }
2387
+ lastResult = criterionValue;
2388
+ peakPower += isAscending ? changeRate : -changeRate;
2389
+ }
2390
+ const totalOptimizedPeakPower = Object.values(resultPeakPower).reduce((a, b) => a + b, 0);
2391
+ const totalPanels = Math.round(totalOptimizedPeakPower / (panelPeakPower / 1e3));
2392
+ const resultData = {
2393
+ mode: "panel",
2394
+ evaluationMode,
2395
+ optimizedPeakPower: parseFloat(totalOptimizedPeakPower.toFixed(2)),
2396
+ estimatedPanels: totalPanels,
2397
+ surfacesPeakpower: resultPeakPower,
2398
+ iterations: Object.keys(optimizationLog).length,
2399
+ optimizationLog
2400
+ };
2401
+ if (opts.apply) {
2402
+ updateStudy(filePath, (s) => {
2403
+ const surfs = s.surfaces;
2404
+ if (surfs) {
2405
+ for (const surf of surfs) {
2406
+ const sid = surf.surfaceId;
2407
+ if (resultPeakPower[sid] !== void 0) {
2408
+ const surfPanels = Math.round(resultPeakPower[sid] / (panelPeakPower / 1e3));
2409
+ surf.panelNumber = surfPanels;
2410
+ surf.installedPower = resultPeakPower[sid] * 1e3;
2411
+ }
2412
+ }
2413
+ }
2414
+ s.peakPowerOptimizationMethod = evaluationMode;
2415
+ return "surfaces";
2416
+ });
2417
+ resultData.applied = true;
2418
+ }
2419
+ output(resultData, global);
2420
+ }
2421
+ } catch (err) {
2422
+ outputError(handleApiError(err));
2423
+ }
2424
+ });
2119
2425
  studies.command("add-comment").description(
2120
2426
  'Add a comment to the local study file.\nExample: suntropy studies add-comment --file study.json --content "Panel layout reviewed"'
2121
2427
  ).option("--file <path>", "Study file path").requiredOption("--content <text>", "Comment text").action(async (opts) => {
@@ -2209,6 +2515,24 @@ async function generateFromProfile(global, tariff, market, year, mode, data) {
2209
2515
  }
2210
2516
  throw new Error(`Unknown consumption mode: ${mode}`);
2211
2517
  }
2518
+ function calculateMaxPanels(areaM2, panel, availablePercentage, panelInclination, surfaceInclination, panelPosition, latitude) {
2519
+ const { heigth, width } = panel;
2520
+ let area = areaM2 * 1e6 * (availablePercentage / 100);
2521
+ const x = 1 / Math.atan(61 - (latitude - surfaceInclination));
2522
+ let totalPanelNumber;
2523
+ if (panelPosition === "horizontal") {
2524
+ const h = width * Math.asin(panelInclination * (Math.PI / 180));
2525
+ const minimumDistance = x * h;
2526
+ const panelArea = (heigth + 20) * (width + minimumDistance);
2527
+ totalPanelNumber = area / panelArea;
2528
+ } else {
2529
+ const h = heigth * Math.asin(panelInclination * (Math.PI / 180));
2530
+ const minimumDistance = x * h;
2531
+ const panelArea = (width + 20) * (heigth + minimumDistance);
2532
+ totalPanelNumber = area / panelArea;
2533
+ }
2534
+ return isNaN(totalPanelNumber) ? 0 : Math.floor(totalPanelNumber);
2535
+ }
2212
2536
  function collectAssets(value, previous) {
2213
2537
  const [idStr, qtyStr] = value.split(":");
2214
2538
  const id = parseInt(idStr);
@@ -2428,7 +2752,7 @@ function registerStudiesCommands(program2) {
2428
2752
  outputError(new Error(`Curve "${curveName}" not found or empty in this study.`));
2429
2753
  return;
2430
2754
  }
2431
- const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class");
2755
+ const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class.js");
2432
2756
  const cd = curveData;
2433
2757
  const pc = new PowerCurve(cd.days || cd, cd.ignore0 ?? false, cd.identifier || curveName, cd.parseDate ?? false);
2434
2758
  const showRaw = opts.raw;
@@ -2522,7 +2846,7 @@ async function readCurveInput(inputPath) {
2522
2846
  return JSON.parse(raw);
2523
2847
  }
2524
2848
  async function buildCurve(data, identifier = "curve") {
2525
- const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class");
2849
+ const { PowerCurve } = await import("energy-types/lib/energy/classes/powerCurve.class.js");
2526
2850
  if (Array.isArray(data)) {
2527
2851
  return new PowerCurve(data, false, identifier, false);
2528
2852
  }