浏览代码

feat(stats): energy usage in cost records and trends (#1432)

The Most Expensive record on the Statistics page ranked prints by
filament cost alone, ignoring the per-print energy cost Bambuddy
already measures via an attached smart plug. It now ranks by
filament + measured energy cost; prints without smart-plug data
compete on filament cost alone, as before.

Filament Trends gains an Energy Over Time chart: kWh per day (per
hour for ranges of a week or less, per week for long ranges), with
the range's total kWh and energy cost in the header. The chart only
renders when the selected range contains measured energy data, so
setups without smart plugs see no change.

The /archives/slim stats feed now carries each run's energy_kwh /
energy_cost from print_log_entries. Translated in all locales.
Covered by backend and frontend tests.
maziggy 1 月之前
父节点
当前提交
d68724c689

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [1.2.6b1] - Unreleased
 
+### Added
+- **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
+
 ### Fixed
 - **The spool PA-Profil (Pressure Advance) picker only ever offered the 0.4mm K-profile, hiding nozzle-specific profiles for the same filament on multi-nozzle printers (#2618)** — When a printer had two K-profiles for one filament differing only in nozzle size (e.g. PAHT-CF at 0.4mm K=0.042 and 0.6mm K=0.028), the **Edit Spool → PA-Profil** tab (and the SpoolBuddy write-tag page, which shares the picker) showed only the 0.4mm entry ("1 match, K=0.042"), regardless of the nozzle actually installed. **Root cause.** Both surfaces fetched a printer's calibrations with `getKProfiles(printer.id)`, which defaults the nozzle filter to `0.4` — and the printer/MQTT layer filters strictly by that diameter, so the 0.6mm profile was never retrieved. (The AMS-Slot config dialog was already fixed for this in #1899; these two pickers were not.) **Fix.** The picker now queries every nozzle the printer reports installed (`0.4`, `0.6`, …) and merges the results, falling back to `0.4` only when the printer hasn't reported its nozzle hardware. Each profile row now also shows a nozzle-diameter badge so two identically-named profiles are distinguishable. Frontend-only. Covered by tests for the nozzle enumeration and the two-profile rendering.
 - **Print-archive backups to a Gitea or Forgejo instance hosted under a URL path prefix could not be configured — the repository URL failed to parse (#2642, reporter @M1ndHunteR)** — Self-hosted Gitea/Forgejo is often served under a subpath (`ROOT_URL` like `https://host/gitea`), so repositories live at `https://host/gitea/owner/repo` rather than at the host root. **Root cause.** The Gitea backend (shared by Forgejo) assumed the repo sat directly under the host: URL parsing required exactly two path segments after the hostname, so a subpath URL's three segments (`gitea/owner/repo`) matched nothing and raised "Cannot parse repository URL". Even had it parsed, the API base was derived from scheme+host only, yielding `https://host/api/v1` instead of `https://host/gitea/api/v1`, so every API call would have 404'd. **Fix.** The Gitea/Forgejo backend now treats the final two path segments as `owner`/`repo` and keeps any leading segments as a base-path prefix, deriving the API base as `{scheme}://{host}{prefix}/api/v1`. Root-hosted instances are unaffected (empty prefix). GitHub/GitLab are untouched. Covered by parse and API-base tests for both providers.

+ 4 - 0
backend/app/api/routes/archives.py

@@ -570,6 +570,8 @@ async def list_archives_slim(
             PrintLogEntry.filament_color,
             PrintLogEntry.status,
             PrintLogEntry.cost,
+            PrintLogEntry.energy_kwh,
+            PrintLogEntry.energy_cost,
             PrintLogEntry.created_at,
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -612,6 +614,8 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
+            "energy_kwh": r.energy_kwh,
+            "energy_cost": r.energy_cost,
             "quantity": 1,
             "created_at": r.created_at,
         }

+ 2 - 0
backend/app/schemas/archive.py

@@ -137,6 +137,8 @@ class ArchiveSlim(BaseModel):
     started_at: datetime | None
     completed_at: datetime | None
     cost: float | None
+    energy_kwh: float | None = None
+    energy_cost: float | None = None
     quantity: int = 1
     created_at: datetime | None
 

+ 23 - 0
backend/tests/integration/test_archives_api.py

@@ -1022,6 +1022,29 @@ class TestArchivesSlimAPI:
         assert "duplicates" not in item
         assert "duplicate_count" not in item
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_includes_energy_fields(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Per-print smart-plug energy surfaces through /slim so the stats
+        page can include it in cost records and trends (#1432)."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            cost=1.50,
+            energy_kwh=0.421,
+            energy_cost=0.063,
+        )
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        item = response.json()[0]
+        assert item["energy_kwh"] == 0.421
+        assert item["energy_cost"] == 0.063
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_slim_computes_actual_time(

+ 40 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -488,6 +488,46 @@ describe('StatsPage', () => {
       });
     });
 
+    it('Most Expensive includes per-print energy cost (#1432)', async () => {
+      // Cheap filament + expensive heated-chamber print must outrank a
+      // pricier-filament print once measured energy cost is added in.
+      server.use(
+        http.get('/api/v1/archives/slim', () =>
+          HttpResponse.json([
+            {
+              id: 20, created_at: '2024-03-01T10:00:00Z',
+              started_at: '2024-03-01T10:00:00Z',
+              completed_at: '2024-03-01T14:00:00Z',
+              print_name: 'PLA Filament Heavy', status: 'completed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#00FF00',
+              filament_used_grams: 200, actual_time_seconds: 14400,
+              print_time_seconds: 14000, cost: 5.00,
+              energy_kwh: null, energy_cost: null, quantity: 1,
+            },
+            {
+              id: 21, created_at: '2024-03-02T10:00:00Z',
+              started_at: '2024-03-02T10:00:00Z',
+              completed_at: '2024-03-02T20:00:00Z',
+              print_name: 'ABS Energy Hog', status: 'completed',
+              printer_id: 1, filament_type: 'ABS', filament_color: '#FF0000',
+              filament_used_grams: 100, actual_time_seconds: 36000,
+              print_time_seconds: 35000, cost: 4.00,
+              energy_kwh: 8.5, energy_cost: 2.55, quantity: 1,
+            },
+          ]),
+        ),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Most Expensive')).toBeInTheDocument();
+      });
+      // 4.00 + 2.55 = 6.55 beats 5.00 flat
+      expect(screen.getByText('$6.55')).toBeInTheDocument();
+      expect(screen.getAllByText('ABS Energy Hog').length).toBeGreaterThan(0);
+    });
+
     it('shows success streak record', async () => {
       render(<StatsPage />);
 

+ 2 - 0
frontend/src/api/client.ts

@@ -727,6 +727,8 @@ export interface ArchiveSlim {
   started_at: string | null;
   completed_at: string | null;
   cost: number | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
   quantity: number;
   created_at: string;
 }

+ 62 - 6
frontend/src/components/FilamentTrends.tsx

@@ -36,16 +36,17 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
 
   // Calculate daily usage data
   const dailyData = useMemo(() => {
-    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; energy: number; prints: number }>();
 
     archives.forEach(archive => {
       const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       // Use local date string for grouping
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
     });
@@ -75,7 +76,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const hourlyData = useMemo(() => {
     if (spanDays > 7) return [];
 
-    const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { date: string; filament: number; cost: number; energy: number; prints: number }>();
     const multiDay = spanDays > 1;
 
     archives.forEach(archive => {
@@ -83,9 +84,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       const h = date.getHours();
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(h).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.cost += archive.cost || 0;
+      existing.energy += archive.energy_kwh || 0;
       existing.prints += archive.quantity || 1;
       dataMap.set(key, existing);
     });
@@ -107,7 +109,7 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const weeklyData = useMemo(() => {
     if (dailyData.length <= 60) return dailyData;
 
-    const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
+    const dataMap = new Map<string, { week: string; filament: number; cost: number; energy: number; prints: number }>();
 
     dailyData.forEach(day => {
       const date = new Date(day.date);
@@ -115,9 +117,10 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
       weekStart.setDate(date.getDate() - date.getDay());
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
-      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
+      const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, energy: 0, prints: 0 };
       existing.filament += day.filament;
       existing.cost += day.cost;
+      existing.energy += day.energy;
       existing.prints += day.prints;
       dataMap.set(key, existing);
     });
@@ -237,6 +240,8 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
   const chartData = spanDays <= 7 && hourlyData.length > 0 ? hourlyData : weeklyData;
   const totalFilament = archives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
   const totalCost = archives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalEnergy = archives.reduce((sum, a) => sum + (a.energy_kwh || 0), 0);
+  const totalEnergyCost = archives.reduce((sum, a) => sum + (a.energy_cost || 0), 0);
   const totalPrints = archives.reduce((sum, a) => sum + (a.quantity || 1), 0);
   const printerCount = new Set(archives.map(a => a.printer_id).filter(Boolean)).size;
 
@@ -323,6 +328,57 @@ export function FilamentTrends({ archives, currency = '$', dateFrom, dateTo }: F
         </div>
       )}
 
+      {/* Energy Over Time Chart (#1432) — only when smart-plug per-print data exists */}
+      {totalEnergy > 0 && chartData.length > 0 && (
+        <div className="bg-bambu-dark rounded-lg p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h4 className="text-sm font-medium text-bambu-gray">{t('stats.energyOverTime')}</h4>
+            <span className="text-xs text-bambu-gray">
+              {totalEnergy.toFixed(3)} kWh · {currency}{totalEnergyCost.toFixed(2)}
+            </span>
+          </div>
+          <ResponsiveContainer width="100%" height={250}>
+            <AreaChart data={chartData}>
+              <defs>
+                <linearGradient id="colorEnergy" x1="0" y1="0" x2="0" y2="1">
+                  <stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3}/>
+                  <stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
+                </linearGradient>
+              </defs>
+              <CartesianGrid strokeDasharray="3 3" stroke="#3d3d3d" />
+              <XAxis
+                dataKey="dateLabel"
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                interval="preserveStartEnd"
+              />
+              <YAxis
+                stroke="#9ca3af"
+                tick={{ fontSize: 12 }}
+                tickFormatter={(value) => `${value}kWh`}
+              />
+              <Tooltip
+                contentStyle={{
+                  backgroundColor: '#2d2d2d',
+                  border: '1px solid #3d3d3d',
+                  borderRadius: '8px',
+                }}
+                labelStyle={{ color: '#fff' }}
+                formatter={(value) => [`${Number(value ?? 0).toFixed(3)} kWh`, t('stats.energyUsed')]}
+              />
+              <Area
+                type="monotone"
+                dataKey="energy"
+                stroke="#f59e0b"
+                strokeWidth={2}
+                fillOpacity={1}
+                fill="url(#colorEnergy)"
+              />
+            </AreaChart>
+          </ResponsiveContainer>
+        </div>
+      )}
+
       {/* Bottom Charts */}
       <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
         {/* Filament Type Distribution */}

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Kosten',
     avgPerPrint: 'Durchschnitt pro Druck',
     usageOverTime: 'Verbrauch im Zeitverlauf',
+    energyOverTime: 'Energie im Zeitverlauf',
     filamentByWeight: 'Gewicht',
     printDuration: 'Druckdauer',
     printerUtilization: 'Druckerauslastung',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -1484,6 +1484,7 @@ export default {
     periodCost: 'Period Cost',
     avgPerPrint: 'Avg per Print',
     usageOverTime: 'Usage Over Time',
+    energyOverTime: 'Energy Over Time',
     filamentByWeight: 'Weight',
     printDuration: 'Print Duration',
     printerUtilization: 'Printer Utilization',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Coste del periodo',
     avgPerPrint: 'Promedio por impresión',
     usageOverTime: 'Uso a lo largo del tiempo',
+    energyOverTime: 'Energía a lo largo del tiempo',
     filamentByWeight: 'Peso',
     printDuration: 'Duración de la impresión',
     printerUtilization: 'Uso de la impresora',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Coût',
     avgPerPrint: 'Moy. par impression',
     usageOverTime: 'Utilisation dans le temps',
+    energyOverTime: 'Énergie dans le temps',
     filamentByWeight: 'Poids',
     printDuration: 'Durée d\'impression',
     printerUtilization: 'Utilisation imprimante',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Costo',
     avgPerPrint: 'Media per stampa',
     usageOverTime: 'Utilizzo nel tempo',
+    energyOverTime: 'Energia nel tempo',
     filamentByWeight: 'Peso',
     printDuration: 'Durata stampa',
     printerUtilization: 'Utilizzo stampante',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -1467,6 +1467,7 @@ export default {
     periodCost: 'コスト',
     avgPerPrint: '1印刷あたりの平均',
     usageOverTime: '時間推移',
+    energyOverTime: 'エネルギー使用量の推移',
     filamentByWeight: '重量',
     printDuration: '印刷時間分布',
     printerUtilization: 'プリンター稼働率',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -1398,6 +1398,7 @@ export default {
     periodCost: '기간 비용',
     avgPerPrint: '인쇄당 평균',
     usageOverTime: '시간별 사용량',
+    energyOverTime: '시간별 에너지 사용량',
     filamentByWeight: '무게',
     printDuration: '인쇄 시간',
     printerUtilization: '프린터 사용률',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: 'Custo',
     avgPerPrint: 'Média por impressão',
     usageOverTime: 'Uso ao longo do tempo',
+    energyOverTime: 'Energia ao longo do tempo',
     filamentByWeight: 'Peso',
     printDuration: 'Duração da impressão',
     printerUtilization: 'Utilização da impressora',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -1395,6 +1395,7 @@ export default {
     periodCost: "Стоимость за период",
     avgPerPrint: "В среднем на печать",
     usageOverTime: "Расход по времени",
+    energyOverTime: "Энергия по времени",
     filamentByWeight: "По весу",
     printDuration: "Продолжительность печати",
     printerUtilization: "Загрузка принтеров",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -1469,6 +1469,7 @@ export default {
     periodCost: 'Dönem Maliyeti',
     avgPerPrint: 'Baskı Başına Ort.',
     usageOverTime: 'Zaman İçinde Kullanım',
+    energyOverTime: 'Zaman İçinde Enerji',
     filamentByWeight: 'Ağırlık',
     printDuration: 'Baskı Süresi',
     printerUtilization: 'Yazıcı Kullanımı',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: '期间成本',
     avgPerPrint: '每次打印平均',
     usageOverTime: '随时间的使用量',
+    energyOverTime: '随时间的能耗',
     filamentByWeight: '重量',
     printDuration: '打印时长',
     printerUtilization: '打印机利用率',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1468,6 +1468,7 @@ export default {
     periodCost: '期間成本',
     avgPerPrint: '每次列印平均',
     usageOverTime: '隨時間的使用量',
+    energyOverTime: '隨時間的能耗',
     filamentByWeight: '重量',
     printDuration: '列印時長',
     printerUtilization: '印表機利用率',

+ 3 - 1
frontend/src/pages/StatsPage.tsx

@@ -882,7 +882,9 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
       });
     }
 
-    const costliest = findMax(a => a.cost);
+    // Filament + measured energy (#1432); prints without a smart plug have
+    // energy_cost null and compete on filament cost alone.
+    const costliest = findMax(a => (a.cost ?? 0) + (a.energy_cost ?? 0));
     if (costliest.archive) {
       result.push({
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),

文件差异内容过多而无法显示
+ 0 - 0
static/assets/index-BnC7vrvV.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-FqCjQymn.js"></script>
+    <script type="module" crossorigin src="/assets/index-BnC7vrvV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   <body>

部分文件因为文件数量过多而无法显示