diff --git a/.github/workflows/deploy-webapp.yml b/.github/workflows/deploy-webapp.yml new file mode 100644 index 00000000..7c4ac13e --- /dev/null +++ b/.github/workflows/deploy-webapp.yml @@ -0,0 +1,61 @@ +# Simple workflow for deploying WebAssembly app to GitHub Pages +name: Deploy WebAssembly app to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + pull_request: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set Up Python + uses: actions/setup-python@v2 + with: + python-version: 3.x + + - name: Install Dependencies + run: | + pip install -r webapp/requirements.txt + + - name: Build the WebAssembly app + run: | + shinylive export webapp site + + - name: Setup Pages + uses: actions/configure-pages@v3 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v2 + with: + # Upload the 'site' directory, where your app has been built + path: "site" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/.ruff.toml b/.ruff.toml index 1cfeeb74..ead4d843 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -26,6 +26,7 @@ ignore = [ [per-file-ignores] "tests/*.py" = ["ALL"] ".github/*py" = ["INP001"] +"webapp/*py" = ["ALL"] [flake8-pytest-style] fixture-parentheses = false diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 00000000..f99369cb --- /dev/null +++ b/webapp/app.py @@ -0,0 +1,268 @@ +"""Simple web app to visualize brightness over time.""" + +import math + +import matplotlib.pyplot as plt +import numpy as np +from shiny import App, render, ui + + +def lerp(x, x1, x2, y1, y2): + """Linearly interpolate between two values.""" + return y1 + (x - x1) * (y2 - y1) / (x2 - x1) + + +def clamp(value: float, minimum: float, maximum: float) -> float: + """Clamp value between minimum and maximum.""" + return max(minimum, min(value, maximum)) + + +def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]: + a = (math.atanh(2 * y2 - 1) - math.atanh(2 * y1 - 1)) / (x2 - x1) + b = x1 - (math.atanh(2 * y1 - 1) / a) + return a, b + + +def scaled_tanh( + x: float, + a: float, + b: float, + y_min: float = 0.0, + y_max: float = 1.0, +) -> float: + """Apply a scaled and shifted tanh function to a given input.""" + return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1) + + +def is_closer_to_sunrise_than_sunset(time, sunrise_time, sunset_time): + """Return True if the time is closer to sunrise than sunset.""" + return abs(time - sunrise_time) < abs(time - sunset_time) + + +def brightness_linear( + time, + sunrise_time, + sunset_time, + time_light, + time_dark, + max_brightness, + min_brightness, +): + """Calculate the brightness for the 'linear' mode.""" + closer_to_sunrise = is_closer_to_sunrise_than_sunset( + time, + sunrise_time, + sunset_time, + ) + if closer_to_sunrise: + brightness = lerp( + time, + x1=sunrise_time - time_dark, + x2=sunrise_time + time_light, + y1=min_brightness, + y2=max_brightness, + ) + else: + brightness = lerp( + time, + x1=sunset_time - time_light, + x2=sunset_time + time_dark, + y1=max_brightness, + y2=min_brightness, + ) + return clamp(brightness, min_brightness, max_brightness) + + +def brightness_tanh( + time, + sunrise_time, + sunset_time, + time_light, + time_dark, + max_brightness, + min_brightness, +): + """Calculate the brightness for the 'tanh' mode.""" + closer_to_sunrise = is_closer_to_sunrise_than_sunset( + time, + sunrise_time, + sunset_time, + ) + if closer_to_sunrise: + a, b = find_a_b( + x1=-time_dark, + x2=time_light, + y1=0.05, # be at 5% of range at x1 + y2=0.95, # be at 95% of range at x2 + ) + brightness = scaled_tanh( + time - sunrise_time, + a=a, + b=b, + y_min=min_brightness, + y_max=max_brightness, + ) + else: + a, b = find_a_b( + x1=-time_light, # shifted timestamp for the start of sunset + x2=time_dark, # shifted timestamp for the end of sunset + y1=0.95, # be at 95% of range at the start of sunset + y2=0.05, # be at 5% of range at the end of sunset + ) + brightness = scaled_tanh( + time - sunset_time, + a=a, + b=b, + y_min=min_brightness, + y_max=max_brightness, + ) + return clamp(brightness, min_brightness, max_brightness) + + +SEC_PER_HR = 60 * 60 + +# Shiny UI +app_ui = ui.page_fluid( + ui.layout_sidebar( + ui.panel_sidebar( + ui.input_slider("min_brightness", "min_brightness", 0, 100, 30, post="%"), + ui.input_slider("max_brightness", "max_brightness", 0, 100, 100, post="%"), + ui.input_slider( + "dark_time", + "brightness_mode_time_dark", + 0, + 5 * SEC_PER_HR, + 3 * SEC_PER_HR, + post=" sec", + ), + ui.input_slider( + "light_time", + "brightness_mode_time_light", + 0, + 5 * SEC_PER_HR, + 0.5 * SEC_PER_HR, + post=" sec", + ), + ui.input_slider( + "sunrise_time", + "sunrise_time", + 0, + 24, + 6, + step=0.5, + post=" hr", + ), + ui.input_slider( + "sunset_time", + "sunset_time", + 0, + 24, + 18, + step=0.5, + post=" hr", + ), + ), + ui.panel_main(ui.output_plot(id="brightness_plot")), + ), +) + + +def server(input, output, session): + @output + @render.plot + def brightness_plot(): + return plot_brightness( + min_brightness=input.min_brightness() / 100, + max_brightness=input.max_brightness() / 100, + brightness_mode_time_dark=input.dark_time() / SEC_PER_HR, + brightness_mode_time_light=input.light_time() / SEC_PER_HR, + sunrise_time=input.sunrise_time(), + sunset_time=input.sunset_time(), + ) + + +def plot_brightness( + min_brightness, + max_brightness, + brightness_mode_time_dark, + brightness_mode_time_light, + sunrise_time=6, # 6 AM + sunset_time=18, # 6 PM +): + # Define the time range for our simulation + time_range = np.linspace(0, 24, 1000) # From 0 to 24 hours + + # Calculate the brightness for each time in the time range for both modes + brightness_linear_values = [ + brightness_linear( + time, + sunrise_time, + sunset_time, + brightness_mode_time_light, + brightness_mode_time_dark, + max_brightness, + min_brightness, + ) + for time in time_range + ] + brightness_tanh_values = [ + brightness_tanh( + time, + sunrise_time, + sunset_time, + brightness_mode_time_light, + brightness_mode_time_dark, + max_brightness, + min_brightness, + ) + for time in time_range + ] + + # Plot the brightness over time for both modes + plt.figure(figsize=(10, 6)) + plt.plot(time_range, brightness_linear_values, label="Linear Mode") + plt.plot(time_range, brightness_tanh_values, label="Tanh Mode") + plt.vlines(sunrise_time, 0, 1, color="C2", label="Sunrise", linestyles="dashed") + plt.vlines(sunset_time, 0, 1, color="C3", label="Sunset", linestyles="dashed") + plt.xlim(0, 24) + plt.xticks(np.arange(0, 25, 1)) + yticks = np.arange(0, 1.05, 0.05) + ytick_labels = [f"{100*label:.0f}%" for label in yticks] + plt.yticks(yticks, ytick_labels) + plt.xlabel("Time (hours)") + plt.ylabel("Brightness") + plt.title("Brightness over Time for Different Modes") + + # Add text box + textstr = "\n".join( + ( + f"Sunrise Time = {sunrise_time}:00:00", + f"Sunset Time = {sunset_time}:00:00", + f"Max Brightness = {max_brightness*100:.0f}%", + f"Min Brightness = {min_brightness*100:.0f}%", + f"Time Light = {brightness_mode_time_light:.1f} hours", + f"Time Dark = {brightness_mode_time_dark:.1f} hours", + ), + ) + + # these are matplotlib.patch.Patch properties + props = {"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5} + + plt.legend() + plt.grid(True) + + # place a text box in upper left in axes coords + plt.gca().text( + 0.4, + 0.55, + textstr, + transform=plt.gca().transAxes, + fontsize=10, + verticalalignment="center", + bbox=props, + ) + + return plt.gcf() + + +app = App(app_ui, server) diff --git a/webapp/requirements.txt b/webapp/requirements.txt new file mode 100644 index 00000000..f832a57a --- /dev/null +++ b/webapp/requirements.txt @@ -0,0 +1 @@ +shinylive