""" 3D scene data and HTML generator for vine, tracker, sun and photosynthesis. Builds JSON-serializable scene data from ShadowModel + CanopyPhotosynthesisModel, and renders an interactive Three.js scene showing which parts of the vine are doing how much photosynthesis (A per zone, colored by rate). """ from __future__ import annotations import json from datetime import date from typing import Any import numpy as np import pandas as pd def build_scene_data( hour: int = 12, date_str: str | None = None, par: float = 1800.0, tleaf: float = 32.0, co2: float = 400.0, vpd: float = 2.5, tair: float = 33.0, ) -> dict[str, Any]: """ Build scene data for the 3D visualization: sun, tracker, vine geometry, shadow mask, PAR and A per zone. Returns a dict suitable for JSON serialization and for build_scene_html(). """ from src.canopy_photosynthesis import CanopyPhotosynthesisModel from src.solar_geometry import ShadowModel shadow = ShadowModel() canopy = CanopyPhotosynthesisModel(shadow_model=shadow) dt_str = date_str or str(date.today()) try: dt = pd.Timestamp(f"{dt_str} {hour:02d}:00:00", tz="Asia/Jerusalem") except Exception: dt = pd.Timestamp(f"{date.today()} {hour:02d}:00:00", tz="Asia/Jerusalem") solar_pos = shadow.get_solar_position(pd.DatetimeIndex([dt])) elev = float(solar_pos["solar_elevation"].iloc[0]) azim = float(solar_pos["solar_azimuth"].iloc[0]) # Sun direction (world: x=East, y=North, z=up), unit vector toward sun elev_rad = np.radians(elev) azim_rad = np.radians(azim) sun_x = np.cos(elev_rad) * np.sin(azim_rad) sun_y = np.cos(elev_rad) * np.cos(azim_rad) sun_z = np.sin(elev_rad) sun_dir = [float(sun_x), float(sun_y), float(sun_z)] if elev <= 2.0: # Night: still return geometry, zero A tracker_theta = 0.0 shadow_mask = np.ones((shadow.n_vertical, shadow.n_horizontal), dtype=bool) par_zones = np.full((shadow.n_vertical, shadow.n_horizontal), par * 0.15) A_zones = np.zeros((shadow.n_vertical, shadow.n_horizontal)) A_vine = 0.0 sunlit_fraction = 0.0 else: tracker = shadow.compute_tracker_tilt(azim, elev) tracker_theta = float(tracker["tracker_theta"]) shadow_mask = shadow.project_shadow(elev, azim, tracker_theta) vine_result = canopy.compute_vine_A( par=par, Tleaf=tleaf, CO2=co2, VPD=vpd, Tair=tair, shadow_mask=shadow_mask, solar_elevation=elev, solar_azimuth=azim, tracker_tilt=tracker_theta, ) par_zones = vine_result["par_zones"] A_zones = vine_result["A_zones"] A_vine = float(vine_result["A_vine"]) sunlit_fraction = float(vine_result["sunlit_fraction"]) # Panel and vine box in world coords (x=East, y=North, z=up) panel_corners = shadow.panel_corners_world(tracker_theta, row_offset=0.0) vine_box = shadow.vine_box_world(row_offset=0.0) # Grid for zone centres (for positioning vine cells in 3D) grid_v = shadow._grid_v.tolist() grid_z = shadow._grid_z.tolist() def to_list(a: np.ndarray) -> list: if a.dtype == bool: return [[bool(x) for x in row] for row in a.tolist()] return [[float(x) for x in row] for row in a.tolist()] return { "hour": hour, "date": dt_str, "sun_elevation": round(elev, 2), "sun_azimuth": round(azim, 2), "sun_direction": sun_dir, "tracker_theta": round(tracker_theta, 2), "panel_corners": panel_corners.tolist(), "vine_box": vine_box.tolist(), "n_vertical": shadow.n_vertical, "n_horizontal": shadow.n_horizontal, "grid_v": grid_v, "grid_z": grid_z, "canopy_width": shadow.canopy_width, "canopy_height": shadow.canopy_height, "shadow_mask": to_list(shadow_mask), "par_zones": to_list(par_zones), "A_zones": to_list(A_zones), "A_vine": round(A_vine, 3), "sunlit_fraction": round(sunlit_fraction, 3), } def build_scene_html(scene_data: dict[str, Any], height_px: int = 480) -> str: """ Generate a self-contained HTML file with a Three.js scene: sun, tracker panel, vine canopy grid colored by photosynthesis rate (A). """ # Three.js uses Y-up; world is x=East, y=North, z=up → we use (x, z, y) for Three def w2t(w: list[float]) -> list[float]: return [w[0], w[2], w[1]] A_zones = scene_data["A_zones"] n_v = scene_data["n_vertical"] n_h = scene_data["n_horizontal"] grid_v = scene_data["grid_v"] grid_z = scene_data["grid_z"] cw = scene_data["canopy_width"] ch = scene_data["canopy_height"] sun_dir = scene_data["sun_direction"] panel_corners = scene_data["panel_corners"] vine_box = scene_data["vine_box"] shadow_mask = scene_data["shadow_mask"] A_flat = [A_zones[iz][ih] for iz in range(n_v) for ih in range(n_h)] A_min = min(A_flat) if A_flat else 0 A_max = max(A_flat) if A_flat else 1 A_range = (A_max - A_min) or 1 # Color gradient: dark green (low A) -> bright green (high A); shaded can be darker def color_for(iz: int, ih: int) -> list[float]: a = A_zones[iz][ih] shaded = shadow_mask[iz][ih] t = (a - A_min) / A_range if A_range else 0 # 0–1 green gradient; shaded dimmed g = 0.2 + 0.7 * t r = 0.1 b = 0.1 if shaded: g *= 0.6 r *= 0.6 b *= 0.6 return [r, g, b] # Zone cell size dv = (cw / n_h) if n_h else 0.1 dz = (ch / n_v) if n_v else 0.1 half_len = 0.4 cells_json = [] for iz in range(n_v): for ih in range(n_h): v_c = grid_v[ih] z_c = grid_z[iz] # World position of cell centre (row-local v,z; u=0 at centre) # In world, row is along u; v is cross-row. We use row_offset=0 so vine at origin. # shadow._row_v, _row_u: world x = v*_row_v[0]+u*_row_u[0], same for y. z = z_c # For centre of row segment: u=0, v=v_c, z=z_c → world (v_c*_row_v[0], v_c*_row_v[1], z_c) # We don't have _row_v in scene_data; approximate: vine_box gives us extent. # Simpler: use local v,z and assume row_u points along -Y (315°), row_v along -X # So world x ≈ -v_c*cos(45°)= -v_c*0.707, y ≈ v_c*0.707, z=z_c. Actually from settings row_azimuth=315. # 315°: along-row = sin(315), cos(315) = -0.707, 0.707. So u direction in world is (-0.707, 0.707, 0). # v direction (cross-row) = cos(315), -sin(315) = 0.707, 0.707. So world = (v*0.707, v*0.707, z). wx = v_c * 0.707 wy = v_c * 0.707 wz = z_c cells_json.append({ "pos": [wx, wz, wy], "color": color_for(iz, ih), "A": A_zones[iz][ih], "shaded": shadow_mask[iz][ih], }) panel_t3 = [w2t(p) for p in panel_corners] sun_t3 = w2t(sun_dir) # Sun sphere position (far along sun direction) sun_dist = 8.0 sun_pos = [sun_t3[0] * sun_dist, sun_t3[1] * sun_dist, sun_t3[2] * sun_dist] scene_json = json.dumps({ "cells": cells_json, "panel": panel_t3, "sun_pos": sun_pos, "sun_dir": sun_t3, "vine_box": [w2t(v) for v in vine_box], "A_vine": scene_data["A_vine"], "sunlit_fraction": scene_data["sunlit_fraction"], "hour": scene_data["hour"], "date": scene_data["date"], "A_max": A_max, "A_min": A_min, }) html = f"""