| """ |
| 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]) |
|
|
| |
| 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: |
| |
| 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_corners = shadow.panel_corners_world(tracker_theta, row_offset=0.0) |
| vine_box = shadow.vine_box_world(row_offset=0.0) |
|
|
| |
| 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). |
| """ |
| |
| 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 |
|
|
| |
| 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 |
| |
| 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] |
|
|
| |
| 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] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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_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"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <title>Vine photosynthesis 3D</title> |
| <style> |
| body {{ margin: 0; overflow: hidden; font-family: system-ui, sans-serif; }} |
| #info {{ position: absolute; left: 8px; top: 8px; color: #eee; background: rgba(0,0,0,0.6); padding: 8px 12px; border-radius: 8px; font-size: 12px; pointer-events: none; }} |
| #legend {{ position: absolute; right: 8px; top: 8px; color: #eee; background: rgba(0,0,0,0.6); padding: 8px 12px; border-radius: 8px; font-size: 11px; pointer-events: none; }} |
| </style> |
| </head> |
| <body> |
| <div id="info">Hour: {scene_data["hour"]:02d}:00 | Date: {scene_data["date"]} | A_vine: {scene_data["A_vine"]:.2f} µmol/m²/s | Sunlit: {scene_data["sunlit_fraction"]*100:.0f}%</div> |
| <div id="legend">Green = photosynthesis rate (dark = low, bright = high). Shaded zones are dimmer.</div> |
| <script type="importmap"> |
| {{ "imports": {{ |
| "three": "https://esm.sh/three", |
| "three/addons/OrbitControls": "https://esm.sh/three/examples/jsm/controls/OrbitControls.js" |
| }} }} |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import {{ OrbitControls }} from 'three/addons/OrbitControls'; |
| |
| const SCENE = {scene_json}; |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x87ceeb); |
| |
| const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100); |
| camera.position.set(4, 3, 4); |
| camera.lookAt(0, 0.6, 0); |
| |
| const renderer = new THREE.WebGLRenderer({{ antialias: true }}); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| document.body.appendChild(renderer.domElement); |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.target.set(0, 0.6, 0); |
| controls.enableDamping = true; |
| |
| // Sun directional light |
| const sunLight = new THREE.DirectionalLight(0xffffcc, 1.2); |
| sunLight.position.set(...SCENE.sun_pos); |
| sunLight.castShadow = false; |
| scene.add(sunLight); |
| |
| // Ambient |
| scene.add(new THREE.AmbientLight(0x4444ff, 0.3)); |
| |
| // Sun sphere |
| const sunGeo = new THREE.SphereGeometry(0.15, 16, 16); |
| const sunMat = new THREE.MeshBasicMaterial({{ color: 0xffdd00 }}); |
| const sunMesh = new THREE.Mesh(sunGeo, sunMat); |
| sunMesh.position.set(...SCENE.sun_pos); |
| scene.add(sunMesh); |
| |
| // Panel (quad) |
| const panelGeom = new THREE.BufferGeometry(); |
| const panelVerts = new Float32Array([ |
| ...SCENE.panel[0], ...SCENE.panel[1], ...SCENE.panel[2], |
| ...SCENE.panel[0], ...SCENE.panel[2], ...SCENE.panel[3], |
| ]); |
| panelGeom.setAttribute('position', new THREE.BufferAttribute(panelVerts, 3)); |
| panelGeom.computeVertexNormals(); |
| const panelMat = new THREE.MeshStandardMaterial({{ color: 0x333333, metalness: 0.6, roughness: 0.4 }}); |
| const panelMesh = new THREE.Mesh(panelGeom, panelMat); |
| scene.add(panelMesh); |
| |
| // Vine zone cells (boxes colored by A) |
| const cellGeoms = []; |
| SCENE.cells.forEach((c, i) => {{ |
| const w = 0.08, h = 0.08, d = 0.5; |
| const box = new THREE.BoxGeometry(w, h, d); |
| const mat = new THREE.MeshStandardMaterial({{ color: new THREE.Color(c.color[0], c.color[1], c.color[2]) }}); |
| const mesh = new THREE.Mesh(box, mat); |
| mesh.position.set(c.pos[0], c.pos[1], c.pos[2]); |
| mesh.rotation.y = Math.PI / 4; |
| scene.add(mesh); |
| cellGeoms.push(mesh); |
| }}); |
| |
| window.addEventListener('resize', () => {{ |
| camera.aspect = window.innerWidth / window.innerHeight; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| }}); |
| |
| function animate() {{ |
| requestAnimationFrame(animate); |
| controls.update(); |
| renderer.render(scene, camera); |
| }} |
| animate(); |
| </script> |
| </body> |
| </html>""" |
| return html |
|
|