diff --git a/.github/workflows/heat-stack.yml b/.github/workflows/heat-stack.yml index 648b2112..9ba2f582 100644 --- a/.github/workflows/heat-stack.yml +++ b/.github/workflows/heat-stack.yml @@ -3,7 +3,8 @@ on: push: branches: - main - # - dev + - gha-deployment + - dev pull_request: {} env: @@ -155,46 +156,46 @@ jobs: # path: playwright-report/ # retention-days: 30 - # deploy: - # name: 🚀 Deploy - # runs-on: ubuntu-latest - # needs: [lint, typecheck, vitest, playwright] - # # only build/deploy main branch on pushes - # if: - # ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && - # github.event_name == 'push' }} + deploy: + name: 🚀 Deploy + runs-on: ubuntu-latest + needs: [lint, typecheck, vitest] #, playwright] + # only build/deploy main branch on pushes + if: + ${{ (github.ref == 'refs/heads/gha-deployment' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && + github.event_name == 'push' }} - # steps: - # - name: ⬇️ Checkout repo - # uses: actions/checkout@v3 + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 - # - name: 👀 Read app name - # uses: SebRollen/toml-action@v1.0.2 - # id: app_name - # with: - # file: 'fly.toml' - # field: 'app' - - # # move Dockerfile to root - # - name: 🚚 Move Dockerfile - # run: | - # mv ./other/Dockerfile ./Dockerfile - # mv ./other/.dockerignore ./.dockerignore - - # - name: 🎈 Setup Fly - # uses: superfly/flyctl-actions/setup-flyctl@v1.4 - - # - name: 🚀 Deploy Staging - # if: ${{ github.ref == 'refs/heads/dev' }} - # run: - # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} - # --app ${{ steps.app_name.outputs.value }}-staging - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - # - name: 🚀 Deploy Production - # if: ${{ github.ref == 'refs/heads/main' }} - # run: - # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.0.2 + id: app_name + with: + file: '${{ env.working-directory }}/fly.toml' + field: 'app' + + # move Dockerfile to root + - name: 🚚 Move Dockerfile + run: | + mv ./other/Dockerfile ./Dockerfile + mv ./other/.dockerignore ./.dockerignore + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@v1.4 + + - name: 🚀 Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --app ${{ steps.app_name.outputs.value }}-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: 🚀 Deploy Production + if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gha-deployment' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/heat-stack/app/root.tsx b/heat-stack/app/root.tsx index 1139e0d7..698db016 100644 --- a/heat-stack/app/root.tsx +++ b/heat-stack/app/root.tsx @@ -1,22 +1,27 @@ import { cssBundleHref } from '@remix-run/css-bundle' -import fontStyleSheetUrl from './styles/font.css' -import tailwindStyleSheetUrl from './styles/tailwind.css' +import { + type DataFunctionArgs, + HeadersFunction, + json, + type LinksFunction, +} from '@remix-run/node' import { Links, Scripts } from '@remix-run/react' +import { CaseSummary } from './components/CaseSummary.tsx' import { href as iconsHref } from './components/ui/icon.tsx' -import { DataFunctionArgs, HeadersFunction, json, type LinksFunction } from '@remix-run/node' +import fontStyleSheetUrl from './styles/font.css' +import tailwindStyleSheetUrl from './styles/tailwind.css' -import { CaseSummary } from './components/CaseSummary.tsx' import './App.css' +import { getUserId } from './utils/auth.server.ts' +import { getHints } from './utils/client-hints.tsx' +import { prisma } from './utils/db.server.ts' +import { getEnv } from './utils/env.server.ts' +import { combineHeaders, getDomainUrl } from './utils/misc.tsx' import { useNonce } from './utils/nonce-provider.ts' import { combineServerTimings, makeTimings, time } from './utils/timing.server.ts' -import { combineHeaders, getDomainUrl } from './utils/misc.tsx' -import { getEnv } from './utils/env.server.ts' // Hints may not be required. Double check. -import { getHints } from './utils/client-hints.tsx' import { WeatherExample } from './components/WeatherExample.tsx' import { Weather } from './WeatherExample.js' -import { getUserId } from './utils/auth.server.ts' -import { prisma } from './utils/db.server.ts' import { csrf } from './utils/csrf.server.ts' import { honeypot } from './utils/honeypot.server.ts' diff --git a/heat-stack/app/utils/temporary_rules_engine.py b/heat-stack/app/utils/temporary_rules_engine.py new file mode 100644 index 00000000..0416d80e --- /dev/null +++ b/heat-stack/app/utils/temporary_rules_engine.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import statistics as sts +from enum import Enum +from typing import List + +import numpy as np + + +def hdd(avg_temp: float, balance_point: float) -> float: + """Calculate the heating degree days on a given day for a given home. + + Args: + avg_temp: average outdoor temperature on a given day + balance_point: outdoor temperature (F) above which no heating is required in a given home + """ + + diff = balance_point - avg_temp + + if diff < 0: + return 0 + else: + return diff + + +def period_hdd(avg_temps: List[float], balance_point: float) -> float: + """Sum up total heating degree days in a given time period for a given home. + + Args: + avg_temps: list of daily average outdoor temperatures (F) for the period + balance_point: outdoor temperature (F) above which no heating is required in a given home + """ + return sum([hdd(temp, balance_point) for temp in avg_temps]) + + +def average_indoor_temp( + tstat_set: float, tstat_setback: float, setback_daily_hrs: float +) -> float: + """Calculates the average indoor temperature. + + Args: + tstat_set: the temp in F at which the home is normally set + tstat_setback: temp in F at which the home is set during off hours + setback_daily_hrs: average # of hours per day the home is at setback temp + """ + # again, not sure if we should check for valid values here or whether we can + # assume those kinds of checks will be handled at the point of user entry + return ( + (24 - setback_daily_hrs) * tstat_set + setback_daily_hrs * tstat_setback + ) / 24 + + +def average_heat_load( + design_set_point: float, + avg_indoor_temp: float, + balance_point: float, + design_temp: float, + ua: float, +) -> float: + """Calculate the average heat load. + + Args: + design_set_point: a standard internal temperature / thermostat set point - different from the preferred set point of an individual homeowner + avg_indoor_temp: average indoor temperature on a given day + balance_point: outdoor temperature (F) above which no heating is required + design_temp: an outside temperature that represents one of the coldest days of the year for the given location of a home + ua: the heat transfer coefficient + """ + return (design_set_point - (avg_indoor_temp - balance_point) - design_temp) * ua + + +def max_heat_load(design_set_point: float, design_temp: float, ua: float) -> float: + """Calculate the max heat load. + + Args: + design_set_point: a standard internal temperature / thermostat set point - different from the preferred set point of an individual homeowner + design_temp: an outside temperature that represents one of the coldest days of the year for the given location of a home + ua: the heat transfer coefficient + """ + return (design_set_point - design_temp) * ua + + +class FuelType(Enum): + """Enum for fuel types. Values are BTU per usage""" + + GAS = 100000 + OIL = 139600 + PROPANE = 91333 + + +class Home: + """Defines attributes and methods for calculating home heat metrics""" + + def __init__( + self, + fuel_type: FuelType, + heat_sys_efficiency: float, + initial_balance_point: float = 60, + thermostat_set_point: float = 68, + has_boiler_for_dhw: bool = False, + same_fuel_dhw_heating: bool = False, + ): + self.fuel_type = fuel_type + self.heat_sys_efficiency = heat_sys_efficiency + self.balance_point = initial_balance_point + self.thermostat_set_point = thermostat_set_point + self.has_boiler_for_dhw = has_boiler_for_dhw + self.same_fuel_dhw_heating = same_fuel_dhw_heating + + def initialize_billing_periods( + self, temps: List[List[float]], usages: List[float], inclusion_codes: List[int] + ) -> None: + """Eventually, this method should categorize the billing periods by + season and calculate avg_non_heating_usage based on that. For now, we + just pass in winter-only heating periods and manually define non-heating + """ + # assume for now that temps and usages have the same number of elements + + self.bills_winter = [] + self.bills_summer = [] + self.bills_shoulder = [] + + # winter months 1; summer months -1; shoulder months 0 + for i in range(len(usages)): + if inclusion_codes[i] == 1: + self.bills_winter.append( + BillingPeriod(temps[i], usages[i], self, inclusion_codes[i]) + ) + elif inclusion_codes[i] == -1: + self.bills_summer.append( + BillingPeriod(temps[i], usages[i], self, inclusion_codes[i]) + ) + else: + self.bills_shoulder.append( + BillingPeriod(temps[i], usages[i], self, inclusion_codes[i]) + ) + + self.calculate_avg_summer_usage() + self.calculate_avg_non_heating_usage() + for bill in self.bills_winter: + bill.initialize_ua() + + def calculate_avg_summer_usage( + self, + ) -> None: + """ + Calculate average daily summer usage + """ + summer_usage_total = sum([bp.usage for bp in self.bills_summer]) + summer_days = sum([bp.days for bp in self.bills_summer]) + if summer_days != 0: + self.avg_summer_usage = summer_usage_total / summer_days + else: + self.avg_summer_usage = 0 + + def calculate_boiler_usage(self, fuel_multiplier: float) -> float: + """Calculate boiler usage with oil or propane + Args: + fuel_multiplier: a constant that's determined by the fuel type + """ + + # self.num_occupants: the number of occupants in Home + # self.water_heat_efficiency: a number indicating how efficient the heating system is + + return 0 * fuel_multiplier + + """ + your pseudocode looks correct provided there's outer logic that + check whether the home uses the same fuel for DHW as for heating. If not, anhu=0. + + From an OO design viewpoint, I don't see Summer_billingPeriods as a direct property + of the home. Rather, it's a property of the Location (an object defining the weather + station, and the Winter, Summer and Shoulder billing periods. Of course, Location + would be a property of the Home. + """ + + def calculate_avg_non_heating_usage( + self, + ) -> None: + """Calculate avg non heating usage for this Home + Args: + #use_same_fuel_DHW_heating + """ + + if self.fuel_type == FuelType.GAS: + self.avg_non_heating_usage = self.avg_summer_usage + elif self.has_boiler_for_dhw and self.same_fuel_dhw_heating: + fuel_multiplier = 1 # default multiplier, for oil, placeholder number + if self.fuel_type == FuelType.PROPANE: + fuel_multiplier = 2 # a placeholder number + self.avg_non_heating_usage = self.calculate_boiler_usage(fuel_multiplier) + else: + self.avg_non_heating_usage = 0 + + def calculate_balance_point_and_ua( + self, + initial_balance_point_sensitivity: float = 2, + stdev_pct_max: float = 0.10, + max_stdev_pct_diff: float = 0.01, + next_balance_point_sensitivity: float = 0.5, + ) -> None: + """Calculates the estimated balance point and UA coefficient for the home, + removing UA outliers based on a normalized standard deviation threshold. + """ + self.uas = [bp.ua for bp in self.bills_winter] + self.avg_ua = sts.mean(self.uas) + self.stdev_pct = sts.pstdev(self.uas) / self.avg_ua + self.refine_balance_point(initial_balance_point_sensitivity) + + while self.stdev_pct > stdev_pct_max: + biggest_outlier_idx = np.argmax( + [abs(bill.ua - self.avg_ua) for bill in self.bills_winter] + ) + outlier = self.bills_winter.pop( + biggest_outlier_idx + ) # removes the biggest outlier + uas_i = [bp.ua for bp in self.bills_winter] + avg_ua_i = sts.mean(uas_i) + stdev_pct_i = sts.pstdev(uas_i) / avg_ua_i + if ( + self.stdev_pct - stdev_pct_i < max_stdev_pct_diff + ): # if it's a small enough change + self.bills_winter.append( + outlier + ) # then it's not worth removing it, and we exit + break # may want some kind of warning to be raised as well + else: + self.uas, self.avg_ua, self.stdev_pct = uas_i, avg_ua_i, stdev_pct_i + + self.refine_balance_point(next_balance_point_sensitivity) + + def calculate_balance_point_and_ua_customizable( + self, + bps_to_remove: List[BillingPeriod], + balance_point_sensitivity: float = 2, + ) -> None: + """Calculates the estimated balance point and UA coefficient for the home based on user input + + Args: + bps_to_remove: a list of Billing Periods that user wishes to remove from calculation + balance_point_sensitivity: the amount to adjust when refining the balance point + """ + + customized_bills = [bp for bp in self.bills_winter if bp not in bps_to_remove] + self.uas = [bp.ua for bp in customized_bills] + self.avg_ua = sts.mean(self.uas) + self.stdev_pct = sts.pstdev(self.uas) / self.avg_ua + + self.bills_winter = customized_bills + self.refine_balance_point(balance_point_sensitivity) + + def refine_balance_point(self, balance_point_sensitivity: float) -> None: + """Tries different balance points plus or minus a given number of degrees, + choosing whichever one minimizes the standard deviation of the UAs. + """ + directions_to_check = [1, -1] + + while directions_to_check: + bp_i = ( + self.balance_point + directions_to_check[0] * balance_point_sensitivity + ) + + if bp_i > self.thermostat_set_point: + break # may want to raise some kind of warning as well + + period_hdds_i = [ + period_hdd(bill.avg_temps, bp_i) for bill in self.bills_winter + ] + uas_i = [ + bill.partial_ua / period_hdds_i[n] + for n, bill in enumerate(self.bills_winter) + ] + avg_ua_i = sts.mean(uas_i) + stdev_pct_i = sts.pstdev(uas_i) / avg_ua_i + + if stdev_pct_i < self.stdev_pct: + self.balance_point, self.avg_ua, self.stdev_pct = ( + bp_i, + avg_ua_i, + stdev_pct_i, + ) + + for n, bill in enumerate(self.bills_winter): + bill.total_hdd = period_hdds_i[n] + bill.ua = uas_i[n] + + if len(directions_to_check) == 2: + directions_to_check.pop(-1) + else: + directions_to_check.pop(0) + + +class BillingPeriod: + def __init__( + self, avg_temps: List[float], usage: float, home: Home, inclusion_code: int + ): + self.avg_temps = avg_temps + self.usage = usage + self.home = home + self.days = len(self.avg_temps) + self.total_hdd = period_hdd(self.avg_temps, self.home.balance_point) + self.inclusion_code = inclusion_code + + def initialize_ua(self): + """average heating usage, partial UA, initial UA. requires that self.home + have non heating usage calculated""" + self.avg_heating_usage = ( + self.usage / self.days + ) - self.home.avg_non_heating_usage + self.partial_ua = self.calculate_partial_ua() + self.ua = self.partial_ua / self.total_hdd + + def calculate_partial_ua(self): + """The portion of UA that is not dependent on the balance point""" + return ( + self.days + * self.avg_heating_usage + * self.home.fuel_type.value + * self.home.heat_sys_efficiency + / 24 + ) diff --git a/heat-stack/fly.toml b/heat-stack/fly.toml index c4908f18..3b9a3200 100644 --- a/heat-stack/fly.toml +++ b/heat-stack/fly.toml @@ -1,5 +1,5 @@ -app = "heat-app-7964" -primary_region = "sjc" +app = "heat-stack" +primary_region = "bos" kill_signal = "SIGINT" kill_timeout = 5 processes = [ ] diff --git a/heat-stack/package.json b/heat-stack/package.json index 873c776b..0f92f690 100644 --- a/heat-stack/package.json +++ b/heat-stack/package.json @@ -7,7 +7,7 @@ "#*": "./*" }, "scripts": { - "postinstall": "patch-package --patch-dir ./patches && patch-package --patch-dir ./other/patches", + "postinstall": "patch-package --patch-dir ./other/patches", "build": "run-s build:*", "build:icons": "tsx ./other/build-icons.ts", "build:remix": "remix build --sourcemap", diff --git a/heat-stack/patches/@remix-run+dev+1.19.1.patch b/heat-stack/patches/@remix-run+dev+1.19.1.patch deleted file mode 100644 index eedfbb29..00000000 --- a/heat-stack/patches/@remix-run+dev+1.19.1.patch +++ /dev/null @@ -1,27 +0,0 @@ -diff --git a/node_modules/@remix-run/dev/dist/compiler/utils/loaders.js b/node_modules/@remix-run/dev/dist/compiler/utils/loaders.js -index 7bb79b6..033429e 100644 ---- a/node_modules/@remix-run/dev/dist/compiler/utils/loaders.js -+++ b/node_modules/@remix-run/dev/dist/compiler/utils/loaders.js -@@ -66,6 +66,7 @@ const loaders = { - ".otf": "file", - ".png": "file", - ".psd": "file", -+ ".py": "text", - ".sql": "text", - ".svg": "file", - ".ts": "ts", -diff --git a/node_modules/@remix-run/dev/dist/modules.d.ts b/node_modules/@remix-run/dev/dist/modules.d.ts -index 25fd1f6..2193142 100644 ---- a/node_modules/@remix-run/dev/dist/modules.d.ts -+++ b/node_modules/@remix-run/dev/dist/modules.d.ts -@@ -115,6 +115,10 @@ declare module "*.psd" { - let asset: string; - export default asset; - } -+declare module "*.py" { -+ let asset: string; -+ export default asset; -+} - declare module "*.sql" { - let asset: string; - export default asset;