2026 HenHacks Submission
An AI-powered solar analysis platform that evaluates any rooftop's solar potential, models 25-year financial returns, and surfaces equity insights across entire cities. The financial calculation engine is implemented in Pharo Smalltalk and called as a live microservice from the Python/FastAPI backend.
- What It Does
- Architecture Overview
- The Pharo Financial Microservice
- Running the Full Stack
- Running Tests
- Environment Variables
- Project Structure
- Rooftop analysis — draw a polygon on the map or drop a pin; the platform fetches satellite imagery, runs a SegFormer-B0 computer-vision model to detect the usable roof area, and calculates how much solar energy that surface can generate per year.
- Financial modelling — given the roof's energy output, a 25-year model projects installation cost, electricity savings, payback period, net profit, and ROI. This step runs in Pharo Smalltalk.
- Equity mapping — a Deck.gl heatmap overlays census-derived equity scores so city planners can see which neighbourhoods would benefit most from solar incentives.
- City-scale scanning — a Celery job fans out across all addresses in a bounding box and ranks every building by solar ROI and equity score.
Browser (React + Vite)
│ REST / WebSocket
▼
FastAPI :8000
│
├─── solar_pipeline.py (11-step analysis orchestrator)
│ │
│ │ Step 6: financial model
│ ▼
│ pharo_bridge.py ──── HTTP POST ────▶ Pharo :8001
│ │ SolarFinancialService
│ │ (if Pharo unreachable) ZnServer + STONJSON
│ ▼
│ financial_model.py (Python fallback, identical maths)
│
├─── NASA POWER API (irradiance data)
├─── Google Maps API (satellite imagery, geocoding)
├─── SegFormer-B0 (roof segmentation, Modal endpoint)
└─── Gemini / Claude (LLM insights)
The 25-year solar financial model — the core output of every analysis — is implemented as a standalone Pharo Smalltalk 13 HTTP microservice. It receives a JSON payload describing a solar installation, runs the full financial computation, and returns a JSON response that the Python pipeline integrates directly into the analysis result shown to the user.
The computed outputs are:
| Field | Description |
|---|---|
system_cost_net |
Net installation cost after the 30 % federal ITC (USD) |
first_year_savings_gross |
Year-1 electricity bill savings (USD) |
first_year_savings_net |
Year-1 savings minus operational costs (USD) |
payback_period_years |
Years until cumulative savings recover the net cost |
total_net_savings_25_years |
25-year total savings minus all operational costs (USD) |
net_profit_25_years |
25-year total savings minus cost of the system (USD) |
roi_25_years |
25-year return on investment (%) |
A second endpoint (/financial/breakdown) returns the full year-by-year table — 25 rows each containing generation (kWh), gross savings, maintenance cost, dust-cleaning cost, and net savings — accounting for panel degradation (0.5 %/yr) and electricity price inflation (2.5 %/yr).
The service is two Smalltalk classes loaded into a headless Pharo 13 image at startup.
A pure computation class with no I/O. All arithmetic mirrors api/services/financial_model.py exactly so that cross-language parity tests can assert numeric equality within a $1 rounding tolerance.
model := SolarFinancialModel new
systemSizeKw: 6.0;
annualGenerationKwh: 8400;
electricityRate: 0.15;
installCostPerWatt: 2.80;
annualMaintenanceRate: 0.012;
dustCleaningsPerYear: 2;
dustCleaningCostPerKw: 14.0;
yourself.
result := model calculate. "→ Dictionary with 7 summary fields"
breakdown := model yearlyBreakdown. "→ OrderedCollection of 25 Dictionaries"An HTTP wrapper around SolarFinancialModel. It uses only built-in Pharo 13 classes — no external packages are loaded:
ZnServer— Zinc HTTP server (ships with the Pharo 13 image)ZnDispatcherDelegate— URL-to-handler routingSTONJSON— JSON serialisation/deserialisation (ships with the Pharo 13 image)
"Route table — /financial/breakdown is mapped before /financial because
the dispatcher does exact-path matching on the string key."
server delegate: (ZnDispatcherDelegate new
map: '/health' to: [ :req :resp | self handleHealth: req response: resp ];
map: '/financial/breakdown' to: [ :req :resp | self handleBreakdown: req response: resp ];
map: '/financial' to: [ :req :resp | self handleFinancial: req response: resp ];
yourself).Each handler parses the request body with STONJSON fromString:, constructs a SolarFinancialModel, runs the calculation, serialises the result with STONJSON toString:, and writes the JSON into the ZnResponse object that ZnDispatcherDelegate provides to each block.
The script that PharoConsole.exe executes at launch. It uses CodeImporter evaluateFileNamed: to file-in the two source files, then calls SolarFinancialService startDefault, which reads the PHARO_SERVICE_PORT environment variable (default 8001) and starts the server. The image then runs as a live HTTP server for as long as the process is alive.
CodeImporter evaluateFileNamed: (FileSystem workingDirectory / 'pharo' / 'src' / 'SolarFinancialModel.st') fullName.
CodeImporter evaluateFileNamed: (FileSystem workingDirectory / 'pharo' / 'src' / 'SolarFinancialService.st') fullName.
(Smalltalk at: #SolarFinancialService) startDefault.api/services/pharo_bridge.py is the async Python client that connects FastAPI to Pharo. It is a thin httpx.AsyncClient wrapper with no blocking calls.
FastAPI request
│
▼
_calculate_financial_with_fallback() ← solar_pipeline.py, Step 6
│
▼
pharo_bridge.calculate_financial_outlook_pharo()
│
│ POST http://localhost:8001/financial
│ { "system_size_kw": 6.0, "annual_generation_kwh": 8400, ... }
│
▼
Pharo SolarFinancialService
│
│ 200 OK { "system_cost_net": 11760, "payback_period_years": 9, ... }
│
▼
dict returned → pipeline continues with Pharo data
Configuration is entirely via environment variables — no hardcoded addresses:
| Variable | Default | Purpose |
|---|---|---|
PHARO_SERVICE_HOST |
localhost |
Host where Pharo is running |
PHARO_SERVICE_PORT |
8001 |
Port Pharo listens on |
PHARO_SERVICE_TIMEOUT |
5.0 |
Request timeout in seconds |
The bridge never raises an exception. If Pharo is unreachable (process not started, wrong port, timeout), pharo_bridge.py returns None and solar_pipeline.py transparently falls back to financial_model.py — the identical maths implemented in Python:
async def _calculate_financial_with_fallback(...) -> dict:
result = await pharo_bridge.calculate_financial_outlook_pharo(...)
if result is not None:
return result # ← Pharo path (preferred)
return calculate_financial_outlook(...) # ← Python fallback (silent)This means:
- The application works correctly in CI and environments without Pharo installed.
- Starting Pharo is opt-in; omitting it degrades gracefully with no user-visible difference.
- Production deployments can run Pharo alongside the API container to exercise the genuine Smalltalk path.
All three endpoints are served directly by the Pharo process.
{ "status": "ok", "version": "1.0" }Request body:
{
"system_size_kw": 6.0,
"annual_generation_kwh": 8400,
"electricity_rate": 0.15,
"install_cost_per_watt": 2.80,
"annual_maintenance_rate": 0.012,
"dust_cleanings_per_year": 2,
"dust_cleaning_cost_per_kw": 14.0,
"include_federal_itc": true
}Response:
{
"system_cost_net": 11760,
"first_year_savings_gross": 1260,
"first_year_savings_net": 890,
"payback_period_years": 9,
"total_net_savings_25_years": 31056,
"net_profit_25_years": 19296,
"roi_25_years": 164.1
}Same request body as /financial. Returns a 25-element array, one entry per year:
[
{
"year": 1,
"generation_kwh": 8400,
"electricity_rate": 0.15,
"gross_savings": 1260,
"maintenance_cost": 202,
"dust_mitigation_cost": 168,
"net_savings": 890
},
{ "year": 2, ... },
...
]- Python 3.11+ —
pip install -r requirements.txt - Node.js 18+ —
cd frontend && npm install - Pharo 13 VM and image — included in
pharo/
Run from the repository root so that FileSystem workingDirectory resolves the pharo/src/ paths correctly:
# Windows
pharo\vm\PharoConsole.exe pharo\image\Pharo13.0-SNAPSHOT-64bit-f201357c22.image st pharo\scripts\load_and_serve.st
# macOS / Linux (adjust binary name for your platform)
./pharo/vm/pharo pharo/image/Pharo13.0-SNAPSHOT-64bit-f201357c22.image st pharo/scripts/load_and_serve.stVerify it is up:
curl http://localhost:8001/health
# → {"status":"ok","version":"1.0"}To run on a different port set PHARO_SERVICE_PORT before the command and set the same value in .env so the bridge finds it:
PHARO_SERVICE_PORT=9001 pharo\vm\PharoConsole.exe ...python -m uvicorn api.main:app --reload --port 8000The backend automatically detects Pharo at localhost:8001 on startup. If Pharo is not running the Python fallback is used silently — no configuration change needed.
cd frontend
npm run dev
# → http://localhost:5173 (or 5174 if 5173 is busy)python -m pytest tests/api/ -m "not pharo_integration" -q
# 229 passedpython -m pytest tests/api/test_pharo_financial.py -m pharo_integration -v
# 9 passedThese 9 tests start the Pharo service, call each endpoint, and assert the Smalltalk output equals the Python reference output within a $1 tolerance for per-year values and a $5 tolerance for 25-year cumulative totals (floating-point rounding accumulates across 25 loop iterations).
python -m pytest tests/api/ -v
# 238 passedcd frontend
npm run typecheck # tsc --noEmit (strict mode)
npm run lint # ESLint
npm run test # Vitest unit testsCopy .env.example to .env. Pharo-specific variables:
| Variable | Default | Description |
|---|---|---|
PHARO_SERVICE_HOST |
localhost |
Host where Pharo is running |
PHARO_SERVICE_PORT |
8001 |
Port Pharo listens on |
PHARO_SERVICE_TIMEOUT |
5.0 |
HTTP timeout (seconds) |
Other required variables (see .env.example for the full list):
| Variable | Description |
|---|---|
GOOGLE_MAPS_API_KEY |
Satellite imagery + map rendering |
GOOGLE_GEOCODING_API_KEY |
Address-to-coordinates lookup |
NASA_POWER_API_BASE_URL |
Solar irradiance data |
ANTHROPIC_API_KEY |
Claude Sonnet 4 (policy briefs, grant summaries) |
GOOGLE_AI_API_KEY |
Gemini 2.5 Flash (real-time insights) |
NREL_API_KEY |
EIA electricity rate lookup |
/
├── api/ # FastAPI backend
│ ├── services/
│ │ ├── solar_pipeline.py # 11-step analysis orchestrator
│ │ ├── pharo_bridge.py # Async HTTP client → Pharo microservice
│ │ ├── financial_model.py # Python fallback (identical maths)
│ │ ├── segmentation.py # SegFormer-B0 roof CV
│ │ ├── irradiance.py # NASA POWER integration
│ │ └── equity_scorer.py # Radiance equity index
│ ├── routers/ # FastAPI route handlers
│ ├── models/ # Pydantic request/response schemas
│ └── main.py
│
├── pharo/ # Pharo Smalltalk financial microservice
│ ├── src/
│ │ ├── SolarFinancialModel.st # Pure 25-year financial calculator
│ │ └── SolarFinancialService.st # ZnServer HTTP wrapper
│ ├── scripts/
│ │ ├── load_and_serve.st # Headless startup script
│ │ └── run_tests.st # SUnit runner
│ ├── tests/
│ │ └── SolarFinancialModelTest.st
│ ├── image/ # Pharo 13 image + clean backup zip
│ └── vm/ # PharoConsole.exe
│
├── frontend/ # React 18 + Vite + TypeScript
│ └── src/
│ ├── components/ # Map, dashboard, equity, city scanner
│ ├── hooks/ # TanStack Query data hooks
│ └── stores/ # Zustand global state
│
├── tests/
│ └── api/
│ └── test_pharo_financial.py # 22 tests: 13 unit + 9 Pharo parity
│
├── workers/ # Celery task workers
├── infra/ # Docker Compose + Kubernetes manifests
└── requirements.txt