@enerlence/suntropy-cli 0.1.4 → 0.3.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.
@@ -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);
@@ -2293,6 +2617,50 @@ var EXPAND_SECTIONS = {
2293
2617
  client: ["clientDetails", "clientsDetails"],
2294
2618
  location: ["location", "mapCenter", "geographicalZone", "atrTariff"]
2295
2619
  };
2620
+ var METADATA_ONLY_FIELDS = {
2621
+ peakPower: "metadata",
2622
+ sellingPrice: "metadata",
2623
+ totalCost: "metadata",
2624
+ anualProduction: "metadata",
2625
+ anualConsumption: "metadata",
2626
+ currentState: "metadata",
2627
+ clientName: "metadata",
2628
+ idSolarStudyMetadata: "metadata",
2629
+ solarStudyId: "metadata"
2630
+ };
2631
+ var COMPUTED_OR_UNKNOWN_FIELDS = {
2632
+ completionPercentage: "computed by `studies validate` / `studies save` from stepsProgress",
2633
+ isCompleted: "not a persisted field; derive from solarStudyProgress or completionPercentage",
2634
+ stepsProgress: "computed by `studies validate` / `studies save`"
2635
+ };
2636
+ function explainMissingStudyFields(missing, studyId) {
2637
+ const metadataFields = missing.filter((f) => f in METADATA_ONLY_FIELDS);
2638
+ const computedFields = missing.filter((f) => f in COMPUTED_OR_UNKNOWN_FIELDS);
2639
+ const unknownFields = missing.filter((f) => !(f in METADATA_ONLY_FIELDS) && !(f in COMPUTED_OR_UNKNOWN_FIELDS));
2640
+ const lines = [];
2641
+ lines.push(`warning: ${missing.length} requested field(s) are not present on the study document returned by findById.`);
2642
+ if (metadataFields.length > 0) {
2643
+ lines.push("");
2644
+ lines.push(` These fields live on the STUDY METADATA (MySQL), not on the study document:`);
2645
+ for (const f of metadataFields) lines.push(` - ${f}`);
2646
+ lines.push(` Use: suntropy studies metadata ${studyId} --by-study-id --fields ${metadataFields.join(",")}`);
2647
+ lines.push(` Or: suntropy studies list --client-name <name> (list already projects these fields)`);
2648
+ }
2649
+ if (computedFields.length > 0) {
2650
+ lines.push("");
2651
+ lines.push(` These fields are not persisted on the study:`);
2652
+ for (const f of computedFields) lines.push(` - ${f}: ${COMPUTED_OR_UNKNOWN_FIELDS[f]}`);
2653
+ lines.push(` Use: suntropy studies validate ${studyId} (returns stepsProgress + completionPercentage)`);
2654
+ }
2655
+ if (unknownFields.length > 0) {
2656
+ lines.push("");
2657
+ lines.push(` These fields were not found on the study document:`);
2658
+ for (const f of unknownFields) lines.push(` - ${f}`);
2659
+ lines.push(` If you expected them, try: suntropy studies get ${studyId} --expand all --format json`);
2660
+ lines.push(` and inspect the full payload to locate the correct field path.`);
2661
+ }
2662
+ process.stderr.write(lines.join("\n") + "\n");
2663
+ }
2296
2664
  var CORE_FIELDS = [
2297
2665
  "_id",
2298
2666
  "id",
@@ -2377,14 +2745,22 @@ function registerStudiesCommands(program2) {
2377
2745
  }
2378
2746
  });
2379
2747
  studies.command("get <studyId>").description(
2380
- "Get solar study by MongoDB ID. By default returns summary (no heavy curves).\nExpand sections: surfaces, results, economics, batteries, consumption, equipment, client, location\nExamples:\n suntropy studies get abc123\n suntropy studies get abc123 --expand surfaces,results\n suntropy studies get abc123 --expand all"
2748
+ "Get solar study by MongoDB ID. By default returns summary (no heavy curves).\nExpand sections: surfaces, results, economics, batteries, consumption, equipment, client, location\nExamples:\n suntropy studies get abc123\n suntropy studies get abc123 --expand surfaces,results\n suntropy studies get abc123 --expand all\n suntropy studies get abc123 --fields name,market (bypasses expand filter)"
2381
2749
  ).option("--expand <sections>", 'Comma-separated sections to expand (or "all")').action(async (studyId, opts) => {
2382
2750
  try {
2383
2751
  const global = getGlobalOpts7(studies);
2384
2752
  const client = createServiceClient("solar", global);
2385
2753
  const res = await client.get(`/solar-study/findById/${studyId}`);
2386
2754
  const study = res.data;
2387
- const filtered = filterStudy(study, opts.expand);
2755
+ const filtered = global.fields ? study : filterStudy(study, opts.expand);
2756
+ if (global.fields) {
2757
+ const requested = global.fields.split(",").map((f) => f.trim());
2758
+ const topLevel = requested.map((f) => f.split(".")[0]);
2759
+ const missing = topLevel.filter((f) => !(f in study));
2760
+ if (missing.length > 0) {
2761
+ explainMissingStudyFields(missing, studyId);
2762
+ }
2763
+ }
2388
2764
  output(filtered, global);
2389
2765
  } catch (err) {
2390
2766
  outputError(handleApiError(err));