"""Solar, a voice-first solar advisor with an illustrated Nigerian home.
Pick your language (shown in its own script), then speak or type your appliances. It
detects what you said, fills the appliances, sizes over the real catalog for your
location, draws your home with the solar panels and your appliances arranged in the
rooms, and speaks the result back in your language. Sizing is exact Python.
"""
from __future__ import annotations
import base64
import difflib
import hashlib
import json
import os
import pathlib
import re
import sys
import threading
import time
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
try:
import buildsmall # noqa
except ModuleNotFoundError:
sys.path.insert(0, str(HERE.parents[1] / "_platform"))
import gradio as gr
import catalog # noqa
import data
import dataset
import engine
import locations
import store # durable accounts + per-user sizing history (shared with the /classic SPA)
import strings # seed testimonials for the reviews wall
from buildsmall import asr, config, llm, sessions, tts, vision
# ── user tracking: count + feedback + optional email ──────────────────────────
# Appends a local JSONL. On a Space, set HF_TOKEN + EVENTS_DATASET and a CommitScheduler
# mirrors it to a private Dataset every few minutes so usage survives restarts. Pull the
# full data privately from that Dataset; only aggregate counts are ever shown in-app.
_DATA_DIR = pathlib.Path(os.environ.get("BUILDSMALL_DATA_DIR", str(HERE / "user_data")))
_EVENTS = _DATA_DIR / "events.jsonl"
_TRACK_LOCK = threading.Lock()
_TRACK_INIT = False
def _init_tracking():
global _TRACK_INIT
if _TRACK_INIT:
return
_TRACK_INIT = True
try:
_DATA_DIR.mkdir(parents=True, exist_ok=True)
repo, token = os.environ.get("EVENTS_DATASET"), os.environ.get("HF_TOKEN")
if repo and token:
from huggingface_hub import CommitScheduler
CommitScheduler(repo_id=repo, repo_type="dataset", folder_path=str(_DATA_DIR),
path_in_repo="data", every=5, token=token, private=True)
except Exception:
pass
def track_event(etype, data=None):
try:
rec = {"type": etype, "data": data or {}, "ts": int(time.time())}
with _TRACK_LOCK, open(_EVENTS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
def get_stats():
n = {"sizings": 0, "emails": 0, "feedback": 0}
m = {"sizing": "sizings", "email": "emails", "feedback": "feedback"}
try:
with open(_EVENTS, encoding="utf-8") as f:
for line in f:
try:
ty = json.loads(line).get("type")
except Exception:
continue
k = m.get(ty)
if k:
n[k] += 1
except FileNotFoundError:
pass
return n
def count_html():
try:
n = get_stats()["sizings"]
except Exception:
n = 0
if not n:
return ""
return ('
%s '
'system%s sized with Naija Solar
') % (format(n, ","), "" if n == 1 else "s")
def submit_feedback(rating, comment):
track_event("feedback", {"rating": rating, "comment": (comment or "").strip()[:400]})
return '
🙏 Thank you, your feedback helps make Naija Solar better.
'
def submit_email(email):
e = (email or "").strip()
if not e:
return ""
if "@" not in e or "." not in e.split("@")[-1] or len(e) > 120 or " " in e:
return '
That email doesn\'t look right, please check it, or leave it blank.
'
track_event("email", {"email": e})
return '
✅ You\'re on the list, only useful solar updates, no spam.
'
UI = {
"en": {"title": "Power your home with the sun", "sub": "Say or type your appliances. I size it, draw your home, and tell you out loud.",
"type": "or type: 1 fridge, 2 fans, 6 bulbs", "fine": "Estimates from current Nigerian prices. Confirm with a licensed installer.",
"empty": "Your home appears here"},
"pcm": {"title": "Power your house with sun", "sub": "Talk or type your appliances. I go size am, draw your house, and yarn am for you.",
"type": "or type: 1 fridge, 2 fans, 6 bulbs", "fine": "Na estimate from current Naija price. Confirm with one licensed installer.",
"empty": "Your house go show here"},
"yo": {"title": "Fi oòrùn pèsè iná ilé rẹ", "sub": "Sọ tàbí kọ àwọn ohun-èlò rẹ. Màá ṣe ìwọ̀n, yá ilé rẹ, kí n sọ ọ́ sókè.",
"type": "tàbí kọ: 1 fridge, 2 fan, 6 bulb", "fine": "Ìfojúsùn láti owó ọjà Nàìjíríà. Jẹ́rìí pẹ̀lú òṣìṣẹ́ tó ní ìwé-àṣẹ.",
"empty": "Ilé rẹ yóò hàn níbí"},
"ha": {"title": "Bayar da wutar gidanka da rana", "sub": "Faɗa ko rubuta kayan aikinka. Zan auna, zana gidanka, in faɗa maka.",
"type": "ko rubuta: 1 fridge, 2 fans, 6 bulbs", "fine": "Kiyasi daga farashin Najeriya na yanzu. Tabbatar da kwararren mai shigarwa.",
"empty": "Gidanka zai bayyana nan"},
"ig": {"title": "Jiri anyanwụ nye ụlọ gị ọkụ", "sub": "Kwuo ma ọ bụ pịnye ngwa gị. M ga-atụle, sere ụlọ gị, kwuo ya n'olu.",
"type": "ma ọ bụ pịnye: 1 fridge, 2 fans, 6 bulbs", "fine": "Atụmatụ site na ọnụahịa Naịjirịa ugbu a. Kwado ya na onye ọrụ nwere ikike.",
"empty": "Ụlọ gị ga-apụta ebe a"},
}
LANG_NAME = {"en": "English", "pcm": "Pidgin", "yo": "Yorùbá", "ha": "Hausa", "ig": "Igbo"}
# Interface labels that also switch with the language (modes + action buttons).
LABELS = {
"en": {"mv": "Speak your appliances", "mt": "Or type them", "mp": "Or snap / upload photos", "bs": "Size it", "bd": "Detect appliances"},
"pcm": {"mv": "Talk your appliances", "mt": "Or type am", "mp": "Or snap / upload photo", "bs": "Size am", "bd": "Detect appliances"},
"yo": {"mv": "Sọ àwọn ohun-èlò rẹ", "mt": "Tàbí kọ wọ́n", "mp": "Tàbí ya fọ́tò", "bs": "Ṣe ìwọ̀n", "bd": "Wá ohun-èlò"},
"ha": {"mv": "Faɗa kayan aikinka", "mt": "Ko rubuta su", "mp": "Ko ɗauki hoto", "bs": "Auna shi", "bd": "Nemo kayan aiki"},
"ig": {"mv": "Kwuo ngwa gị", "mt": "Ma ọ bụ pịnye ha", "mp": "Ma ọ bụ sere foto", "bs": "Tụọ ya", "bd": "Chọta ngwa"},
}
# Scope guardrail: shown when the input is not about appliances or solar.
GUARD = {
"en": "I only size solar systems from home appliances, so I do not know that and I am not trained for it. Try: one fridge, two fans, six bulbs.",
"pcm": "Na only your appliances I sabi use size solar, so I no know that one and dem no train me for am. Try: one fridge, two fans, six bulbs.",
"yo": "Awon ohun-elo ile nikan ni mo le fi se isiro oorun, nitori naa emi ko mo nkan yen, won ko si ko mi fun un. Gbiyanju: firiji kan, fan meji, bulb mefa.",
"ha": "Kayan aikin gida kawai nake iya auna hasken rana da su, don haka ban san wannan ba kuma ba a horar da ni a kai ba. Gwada: firiji daya, fanka biyu, kwararan fitila shida.",
"ig": "Naani ngwa ulo ka m ji atule anyanwu, ya mere amaghi m nke ahu na-azughi m maka ya. Nwaa: otu friji, fan abuo, baolb isii.",
}
EMOJI = {"Fridge (small)": "🧊", "Chest freezer": "🧊", "Ceiling fan": "🌀", "Standing fan": "🌀",
'TV (32–43" LED)': "📺", "TV (large)": "📺", "Decoder (DStv/GOtv)": "📡", "Sound system": "🔊",
"LED bulb": "💡", "Energy-saver bulb": "💡", "Security light": "💡", "Laptop": "💻", "Desktop PC": "🖥️",
"Phone charger": "📱", "Wifi router": "📶", "Air conditioner (1HP)": "❄️", "Air conditioner (1.5HP)": "❄️",
"Air conditioner (2HP)": "❄️", "Water pump (0.5HP)": "🚰", "Water pump (1HP)": "🚰", "Electric iron": "👔",
"Microwave": "🍲", "Blender": "🥤", "Electric kettle": "☕", "Washing machine": "🧺", "Water heater": "♨️"}
# ── appliance language parsing (deterministic) ─────────────────────────────────
NUM = {"a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10}
KW = [("ceiling fan", "Ceiling fan"), ("standing fan", "Standing fan"), ("security light", "Security light"),
("air condition", "Air conditioner (1HP)"), ("washing machine", "Washing machine"),
("water heater", "Water heater"), ("water pump", "Water pump (1HP)"), ("pumping machine", "Water pump (1HP)"),
("sound system", "Sound system"), ("deep freezer", "Chest freezer"), ("chest freezer", "Chest freezer"),
("phone charger", "Phone charger"), ("flat screen", 'TV (32–43" LED)'), ("television", 'TV (32–43" LED)'),
("decoder", "Decoder (DStv/GOtv)"), ("dstv", "Decoder (DStv/GOtv)"), ("gotv", "Decoder (DStv/GOtv)"),
("microwave", "Microwave"), ("blender", "Blender"), ("kettle", "Electric kettle"),
("pressing iron", "Electric iron"), ("electric iron", "Electric iron"), ("desktop", "Desktop PC"),
("computer", "Desktop PC"), ("laptop", "Laptop"), ("router", "Wifi router"), ("wifi", "Wifi router"),
("freezer", "Chest freezer"), ("fridge", "Fridge (small)"), ("refrigerator", "Fridge (small)"),
("ac", "Air conditioner (1HP)"), ("pump", "Water pump (1HP)"), ("iron", "Electric iron"),
("fan", "Standing fan"), ("tv", 'TV (32–43" LED)'), ("bulb", "LED bulb"), ("light", "LED bulb"),
("lamp", "LED bulb"), ("phone", "Phone charger"), ("charger", "Phone charger"),
("aircon", "Air conditioner (1HP)"), ("washer", "Washing machine"), ("telly", 'TV (32–43" LED)'),
("tele", 'TV (32–43" LED)'), ("freeze", "Chest freezer")]
KW2 = [(k, a) for k, a in KW if " " in k]
KW1 = [(k, a) for k, a in KW if " " not in k]
_KW1_MAP = dict(KW1)
_FUZZ_KEYS = [k for k, _ in KW1 if len(k) >= 5] # long keys only, safe to fuzzy-match
def _base(t):
return t[:-1] if (t.endswith("s") and len(t) > 2) else t
def _fuzz(t):
"""Closest appliance keyword for a misspelled token (e.g. 'frige' -> fridge, 'televison' -> TV)."""
if len(t) < 5:
return None
m = difflib.get_close_matches(t, _FUZZ_KEYS, n=1, cutoff=0.82)
return _KW1_MAP[m[0]] if m else None
def _m1(t):
for k, a in KW1:
if t == k or _base(t) == k:
return a
return _fuzz(_base(t))
def _m2(a, b):
for k, ap in KW2:
w0, w1 = k.split(" ", 1)
if a == w0 and (b == w1 or _base(b) == w1 or
(len(w1) >= 5 and difflib.SequenceMatcher(None, _base(b), w1).ratio() >= 0.82)):
return ap
return None
def parse_appliances(text):
toks = re.sub(r"[^\w\s]", " ", (text or "").lower()).split()
sel, pending, i = {}, None, 0
while i < len(toks):
tok = toks[i]
if tok.isdigit():
pending = (int(tok), i); i += 1; continue
if tok in NUM:
pending = (NUM[tok], i); i += 1; continue
appl, adv = None, 1
if i + 1 < len(toks):
appl = _m2(tok, toks[i + 1])
if appl:
adv = 2
appl = appl or _m1(tok)
if appl:
qty = pending[0] if (pending and i - pending[1] <= 3) else 1
sel[appl] = max(sel.get(appl, 0), qty)
pending = None; i += adv; continue
i += 1
return sel
def extract(text):
sel = parse_appliances(text)
if sel:
return sel
try:
out = llm.json(f"List home appliances and integer quantities from: '{text}'. Use ONLY names from "
f"this list: {list(data.APPLIANCES)}. Reply a JSON object name to count.",
model="Qwen/Qwen3-1.7B") # Qwen reads messy free-text reliably (MiniCPM5-1B overthinks structured output)
if isinstance(out, dict):
return {k: int(v) for k, v in out.items() if k in data.APPLIANCES and str(v).strip().lstrip('-').isdigit()}
except Exception:
pass
return sel
def detect_language(text):
t = (text or "").lower()
for code, marks in (("yo", ("ẹ", "ọ", "ṣ", "bawo", "jọwọ", "ilé", "mo ní")),
("ig", ("kedu", "biko", "achọrọ", "ụlọ", "enwere")),
("ha", ("ina", "kana", "yaya", "gida", "muna", "ina so")),
("pcm", ("abeg", "wetin", " dey ", " na ", "wahala", "oga", "i get"))):
if any(m in t for m in marks):
return code
return "en"
def _cost_phrase(n):
"""A speech-friendly amount: 1,924,000 -> '1.9 million', 950,000 -> '950 thousand'.
Clearer to read and to hear than a long string of digits with commas."""
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}".rstrip("0").rstrip(".") + " million"
if n >= 1000:
return f"{round(n / 1000)} thousand"
return str(n)
def _template(r, lang):
p, kva = r["panel"]["count"], r["inverter"]["kva"]
b, kwh = r["batteries"]["durable"]["count"], r["daily_kwh"]
cost = _cost_phrase(r["batteries"]["durable"]["total"])
pw, bw = ("panel" if p == 1 else "panels"), ("battery" if b == 1 else "batteries")
T = {
"en": f"You use about {kwh} kilowatt hours a day. I recommend {p} solar {pw}, a {kva} kVA inverter, and {b} {bw}. The system costs about {cost} naira. Please confirm with a licensed installer.",
"pcm": f"You dey use about {kwh} kilowatt every day. You go need {p} solar {pw}, one {kva} kVA inverter, and {b} {bw}. E go cost around {cost} naira. Make you confirm with a licensed installer.",
"yo": f"O ń lo tó {kwh} kilowatt lójúmọ́. Mo dábàá panel oòrùn {p}, inverter {kva} kVA kan, àti bátìrì {b}. Ètò náà yóò ná nǹkan bíi náírà {cost}. Jọ̀wọ́ bèèrè lọ́wọ́ onímọ̀ tó ní ìwé àṣẹ.",
"ha": f"Kana amfani da kusan {kwh} kilowatt a kullum. Ina ba da shawarar panel hasken rana {p}, inverter {kva} kVA ɗaya, da batir {b}. Tsarin zai kai kusan naira {cost}. Don Allah ka tabbatar da ƙwararren mai shigarwa.",
"ig": f"Ị na-eji ihe dịka {kwh} kilowatt kwa ụbọchị. Ana m atụ aro panel anyanwụ {p}, otu inverter {kva} kVA, na batrị {b}. Usoro a ga-efu ihe dịka naịra {cost}. Biko kwado ya na onye ọrụ nwere ikike.",
}
return T.get(lang, T["en"])
# ── speech-friendly narration for the Nigerian languages ──────────────────────
# SoroTTS is a Yorùbá/Hausa/Igbo voice: it reads pure prose beautifully but stumbles on English
# digits and units ("2.66", "1.5 kVA") read mid-sentence, which is what made the voice sound off.
# So for yo/ha/ig the narration spells counts as words, drops the hardest units, and rounds the cost
# to a spoken approximation. The exact figures still show in the result tiles, and because this same
# text is both shown and read, "you hear what you read" still holds. English/Pidgin keep the precise
# wording (their voices read digits fine).
_NUM = {
"yo": ["", "ọ̀kan", "méjì", "mẹ́ta", "mẹ́rin", "márùn-ún", "mẹ́fà", "méje", "mẹ́jọ", "mẹ́sàn-án", "mẹ́wàá",
"mọ́kànlá", "méjìlá", "mẹ́tàlá", "mẹ́rìnlá", "mẹ́ẹ̀ẹ́dógún", "mẹ́rìndínlógún", "mẹ́tàdínlógún",
"méjìdínlógún", "mọ́kàndínlógún", "ogún"],
"ha": ["", "ɗaya", "biyu", "uku", "huɗu", "biyar", "shida", "bakwai", "takwas", "tara", "goma",
"goma sha ɗaya", "goma sha biyu", "goma sha uku", "goma sha huɗu", "goma sha biyar",
"goma sha shida", "goma sha bakwai", "goma sha takwas", "goma sha tara", "ashirin"],
"ig": ["", "otu", "abụọ", "atọ", "anọ", "ise", "isii", "asaa", "asatọ", "itoolu", "iri",
"iri na otu", "iri na abụọ", "iri na atọ", "iri na anọ", "iri na ise", "iri na isii",
"iri na asaa", "iri na asatọ", "iri na itoolu", "iri abụọ"],
}
def _num_words(n, lang):
t = _NUM.get(lang)
try:
n = int(n)
except (TypeError, ValueError):
return str(n)
return t[n] if (t and 1 <= n <= 20) else str(n)
def _cost_words(total, lang):
"""A spoken, mostly-round amount in millions (sub-million numerals are unspeakable in these
languages). The exact naira figure is shown in the cost card."""
m = round((total or 0) / 1_000_000)
if m < 1:
return {"yo": "ìdajì mílíọ̀nù náírà", "ha": "rabin miliyan naira", "ig": "ọkara nde naịra"}[lang]
w = _num_words(m, lang)
return {"yo": f"mílíọ̀nù náírà {w}", "ha": f"naira miliyan {w}", "ig": f"naịra nde {w}"}[lang]
def _template_tts(r, lang):
"""Speech-friendly narration for yo/ha/ig (numbers as words, hard units dropped, cost rounded);
English/Pidgin fall back to the precise template."""
if lang not in ("yo", "ha", "ig"):
return _template(r, lang)
p, b = r["panel"]["count"], r["batteries"]["durable"]["count"]
kw = max(1, round(r["daily_kwh"]))
P, B, KW = _num_words(p, lang), _num_words(b, lang), _num_words(kw, lang)
C = _cost_words(r["batteries"]["durable"]["total"], lang)
if lang == "yo":
return (f"Iná tí o ń lò tó ìwọ̀n {KW} lójoojúmọ́. Mo dábàá pánẹ́ẹ̀lì oòrùn {P}, "
f"ẹ̀rọ̀ amúná ọ̀kan, àti bátìrì {B}. Ètò náà yóò ná tó {C}. "
f"Jọ̀wọ́ bèèrè lọ́wọ́ onímọ̀ tó ní ìwé àṣẹ.")
if lang == "ha":
return (f"Kana amfani da wuta kusan {KW} a kullum. Ina ba da shawarar panel hasken rana {P}, "
f"inverter ɗaya, da batir {B}. Tsarin zai kai kusan {C}. "
f"Don Allah ka tabbatar da ƙwararren mai shigarwa.")
return (f"Ị na-eji ọkụ dịka {KW} kwa ụbọchị. Ana m atụ aro panel anyanwụ {P}, "
f"otu inverter, na batrị {B}. Usoro a ga-efu ihe dịka {C}. "
f"Biko kwado ya na onye ọrụ nwere ikike.")
def _clean(t):
return re.sub(r".*?", "", t or "", flags=re.DOTALL).strip()
def narrate(r, lang):
"""The plan in one localized paragraph. It is shown on screen AND read aloud, so the written
words and the spoken words are always identical. It is a deterministic template, so it is
instant (no model call before the voice), reusable from cache, and dependable in all five
languages, which a 1.7B model is not for Yoruba, Hausa or Igbo. For yo/ha/ig the wording is
speech-friendly (numbers as words, no bare digits/units), which is what the voice reads cleanly;
the exact figures live in the result tiles."""
return _template_tts(r, lang)
def _for_tts(text):
"""Light touch so the voice reads the plan clearly: speak 'kVA' as letters, never as a word."""
return (text or "").replace("kVA", "k V A").replace("kWh", "kilowatt hours")
# ── original illustrated artwork (SVG, comic style, renders as an image) ─────────
def _img(svg, cls):
return f''
def _sun(cx, cy, r, col="#ffd23f", out="#e8a200"):
rl = "".join(f'' for a in range(0, 360, 45))
return (f''
f'{rl}')
def _palm(x):
return (f''
f''
f''
f''
f''
f''
f'')
def banner_svg():
def home(x, w, body, roof="#a8553c"):
cx = x + w // 2
return (''
''
''
''
''
''
''
) % (x, w, body, x - 6, cx, x + w + 6, roof, x + 6, w - 12, cx, cx, cx - 5, x + w - 16)
def tree(x, s=1.0):
return (''
''
''
) % (x, x + 3, int(15 * s), x - 6, int(11 * s), x + 11, int(12 * s))
glow = (''
'')
svg = ('')
return base64.b64encode(svg.encode()).decode()
BANNER = banner_svg()
def house_scene(panels, batts, sel, empty=""):
p = max(0, int(panels)); show = min(p, 12)
cells = ""
for i in range(show):
c, row = i % 4, i // 3
x, y = 236 + c * 30, 152 + row * 13
cells += (''
'') % (x, y, x, y)
label = ('%d solar panels' % p) if p else ""
cols, rowsy = [194, 303, 413], [232, 312]
floors = (''
'') if sel else ""
div = (''
'') if sel else ""
icons = ""
for idx, (name, qty) in enumerate(list(sel.items())[:6]):
cx, cy = cols[idx % 3], rowsy[idx // 3]
icons += '%s' % (cx, cy + 10, EMOJI.get(name, "🔌"))
if qty > 1:
icons += (''
'%d'
) % (cx + 25, cy - 18, cx + 25, cy - 14, qty)
extra = '+%d more in the list below' % (len(sel) - 6) if len(sel) > 6 else ""
ph = '%s' % empty if not sel else ""
batt = ""
for j in range(min(int(batts), 4)):
batt += '' % (90 + (j % 2) * 23, 322 - (j // 2) * 31)
svg = ('')
return _img(svg, "home")
def chips_html(sel):
items = "".join(
'
%s%d%s
'
% (EMOJI.get(k, "🔌"), v, k.split(" (")[0]) for k, v in sel.items())
return '
✅ Your appliances
%s
' % items
def day_chart_svg(r):
"""Signature 24-hour visualization: solar generation vs the home's usage."""
import math
W, H, L, Rr, T, B = 600, 248, 46, 14, 30, 30
cw, ch = W - L - Rr, H - T - B
array_kw = max(0.4, r["array_w"] / 1000.0)
daily = max(0.5, r["daily_kwh"])
def gen(h):
return array_kw * (math.sin(math.pi * (h - 6) / 13.5) ** 1.5) if 6 < h < 19.5 else 0.0
def shape(h):
return 0.3 + 1.0 * math.exp(-((h - 7.5) ** 2) / 5.0) + 1.7 * math.exp(-((h - 20.0) ** 2) / 6.0)
hrs = [i * 0.5 for i in range(49)]
sc = daily / max(sum(shape(h) for h in hrs) * 0.5, 0.1)
def load(h):
return shape(h) * sc
ymax = max(array_kw, max(load(h) for h in hrs)) * 1.16
X = lambda h: L + (h / 24.0) * cw
Y = lambda v: T + ch - (v / ymax) * ch
def apath(fn):
return ("M%.1f,%.1f" % (X(0), Y(0)) + "".join("L%.1f,%.1f" % (X(h), Y(fn(h))) for h in hrs)
+ "L%.1f,%.1fZ" % (X(24), Y(0)))
def lpath(fn):
return "M" + "L".join("%.1f,%.1f" % (X(h), Y(fn(h))) for h in hrs)
grid = ""
for hh, lab in [(0, "12am"), (6, "6am"), (12, "12pm"), (18, "6pm"), (24, "12am")]:
grid += (''
'%s'
% (X(hh), T, X(hh), T + ch, X(hh), H - 9, lab))
svg = ('')
legend = ('
Solar generation'
'Your usage
')
return '
☀️ Your day: sun vs usage
%s%s
' % (svg, legend)
def system_view(r):
return day_chart_svg(r) + energy_flow_svg(r)
def home_2d_svg(r):
"""Clean 2D illustrated home + system view, a labelled companion to the 3D (always renders)."""
panels, kva = r["panel"]["count"], r["inverter"]["kva"]
d = r["batteries"]["durable"]
items = r.get("profile", {}).get("items", [])[:8]
appl = ""
for i, it in enumerate(items):
cx, cy = 84 + (i % 4) * 60, 198 + (i // 4) * 52
appl += ('%s'
'x%d'
% (cx, cy, EMOJI.get(it["name"], "\U0001f50c"), cx, cy + 15, it["qty"]))
pan = "".join(''
% (62 + i * 37) for i in range(min(panels, 6)))
svg = ('')
return '
\U0001f5bc️ Your home & system (2D)
%s
' % svg
def energy_flow_svg(r):
"""B: a live engineering view, energy flowing sun to panels to battery to inverter to loads."""
panels, arrayw, kva = r["panel"]["count"], r["array_w"], r["inverter"]["kva"]
bk = r["batteries"]["durable"]
backup, nb, loadw, daily = bk["backup_kwh"], bk["count"], r["peak_w"], r["daily_kwh"]
rays = "".join('' % a for a in range(0, 360, 45))
def flow(x1, x2, color):
dots = "".join('' % (color, k * 0.6, x1, x2) for k in range(3))
return '%s' % (x1, x2, dots)
def node(x, w, fill, title, big, sub):
cx = x + w / 2
return (''
'%s'
'%s'
'%s'
) % (x, w, fill, cx, title, cx, big, cx, sub)
parts = (flow(66, 150, "#ffd23f") + node(150, 108, "#1f4fd0", "Solar", str(panels), "panels %sW" % arrayw)
+ flow(258, 312, "#7cf2c0") + node(312, 108, "#1f9d4a", "Battery", str(backup), "kWh %su" % nb)
+ flow(420, 474, "#c9a3ff") + node(474, 108, "#6b3fd0", "Inverter", str(kva), "kVA")
+ flow(582, 624, "#ffb15a") + node(624, 86, "#b4530a", "Loads", str(loadw), "W %skWh/d" % daily))
svg = ('')
return _img(svg, "flow")
def load_breakdown_html(sel):
rows = sorted(((k, data.APPLIANCES[k][0] * v, v) for k, v in sel.items()), key=lambda x: -x[1])
mx = max((w for _, w, _ in rows), default=1)
bars = "".join('
%s %s x%d
'
'
%d W
' % (EMOJI.get(k, "🔌"), k.split(" (")[0], v, max(6, round(100 * w / mx)), w)
for k, w, v in rows)
return '
'
def _gen_card(r):
"""For small loads, suggest a plug-and-play portable solar generator as an alternative."""
if r["peak_w"] > 2400 or r["daily_kwh"] > 4:
return ""
g = dataset.pick_generator(r["peak_w"], r["daily_kwh"])
if not g:
return ""
return ('
🧳
Or a portable option: %s %s
'
'
%d Wh · %d W · about ₦%s, plug-and-play, add a panel to recharge
') % (
g["brand"], g["model"], g["wh"], g["w"], format(g["price_ngn"], ","))
def rec_cards(r):
d = r["batteries"]["durable"]
rows = [("☀️", f"{r['panel']['count']} solar panels", f"{r['panel']['name']}, about {r['array_w']} W"),
("🔌", "Inverter", r["inverter"]["name"]),
("🔋", f"{d['count']} battery", f"{d['name']}, {d['backup_kwh']} kWh backup")]
cards = "".join(
f'
{i}
{t}
{s}
'
for i, t, s in rows)
return '
' + cards + _gen_card(r) + '
'
def vendors_html():
fin = {"PAYG": "Pay small-small", "lease": "Lease monthly", "yes": "Installments"}
def card(v):
tag = fin.get(v.get("financing"), "")
chip = ('💳 %s' % tag) if tag else ""
offers = ", ".join(v.get("offers", [])[:2])
return (''
'
') % (
v["website"], v["name"], chip, offers, v.get("coverage", "Nigeria"))
fv = [v for v in dataset.VENDORS if v.get("financing") in fin]
dv = [v for v in dataset.VENDORS if v.get("financing") not in fin]
out = ('
Vetted Nigerian sellers. The ones marked 💳 let you spread the cost over time, '
'which makes a system like this far easier to afford. Always confirm current prices and warranty.
')
if fv:
out += ('
💳 Pay over time (financing)
%s
'
% "".join(card(v) for v in fv[:8]))
out += '
🛒 Buy direct
%s
' % "".join(card(v) for v in dv[:8])
return out
def logo_html():
return ('
Naija Solar'
'Solar sizing in your language'
'live
')
# Localized first-time guide (yo/ha/ig are best-effort and deserve a native review).
GUIDE_TEXT = {
"en": {"why": "Why Naija Solar? Millions of Nigerian homes and shops face daily blackouts and burn money on petrol and diesel. A real solar consultation costs time and money, so most people guess or overpay. Naija Solar gives you a free first estimate in your own language: say, type, or snap your appliances and it sizes a proper solar system with real Nigerian prices.",
"steps": [("🗣️", "1. Pick your language", "Tap a language above."),
("🎤", "2. Say, type, or snap your appliances", "Use voice, the text box, or up to 5 photos."),
("🏡", "3. See your home and hear the plan", "Your home, the system, the cost, spoken aloud.")],
"privacy": "🔒 Private, nothing is saved to an account. I only size solar from appliances, not wiring, medical, financial, or legal advice. Estimates only."},
"pcm": {"why": "Why Naija Solar? Plenty Naija house and shop dey see blackout every day and dey waste money for fuel and diesel. To call solar person come check fit cost time and money, so most people just dey guess or pay too much. Naija Solar go give you free first estimate for your own language: talk, type, or snap your gadgets and e go size correct solar system with real Naija price.",
"steps": [("🗣️", "1. Choose your language", "Tap one language for up."),
("🎤", "2. Talk, type, or snap your things", "Use voice, the text box, or up to 5 photo."),
("🏡", "3. See your house, hear the plan", "Your house, the system, the price, e go talk am.")],
"privacy": "🔒 Private, we no dey save anything. I dey only size solar from your gadgets, no be wiring, medical, money, or law advice. Na estimate."},
"yo": {"why": "Kí ló dé Naija Solar? Ọ̀pọ̀ ilé àti ṣọ́ọ̀bù ní Nàìjíríà ni iná máa ń kú lójoojúmọ́, wọ́n sì ń ná owó lórí epo. Pípe ọ̀jọ̀gbọ́n solar lè ná owó àti àkókò, torí náà ọ̀pọ̀ ènìyàn máa ń fojú díwọ̀n tàbí san jù. Naija Solar yóò fún ọ ní ìdíwọ̀n àkọ́kọ́ ọ̀fẹ́ ní èdè rẹ: sọ, kọ, tàbí ya àwòrán ohun-èlò rẹ.",
"steps": [("🗣️", "1. Yan èdè rẹ", "Tẹ èdè kan lókè."),
("🎤", "2. Sọ, kọ, tàbí ya àwòrán", "Lo ohùn, àpótí ọ̀rọ̀, tàbí àwòrán márùn-ún."),
("🏡", "3. Rí ilé rẹ, gbọ́ ètò náà", "Ilé rẹ, ètò náà, iye owó, a ó sọ ọ́.")],
"privacy": "🔒 Àdáni, kò sí ohun tí a fipamọ́. Mo ń díwọ̀n solar láti inú ohun-èlò nìkan, kì í ṣe ìmọ̀ràn waya, ìṣègùn, owó, tàbí òfin. Ìdíwọ̀n nìkan."},
"ha": {"why": "Me ya sa Naija Solar? Gidaje da shaguna da yawa a Najeriya na fuskantar yankewar wuta kullum suna kuma kashe kuɗi a kan man fetur da dizal. Kawo masanin solar na iya ɗaukar lokaci da kuɗi, don haka mutane da yawa suna ƙididdiga ko biya fiye da kima. Naija Solar zai ba ka kiyasi na farko kyauta a yarenka: faɗa, rubuta, ko ɗauki hoton kayanka.",
"steps": [("🗣️", "1. Zaɓi yarenka", "Danna yare a sama."),
("🎤", "2. Faɗa, rubuta, ko ɗauki hoto", "Yi amfani da murya, akwatin rubutu, ko hotuna biyar."),
("🏡", "3. Ga gidanka, ji shirin", "Gidanka, tsarin, farashin, za a faɗa da murya.")],
"privacy": "🔒 Na sirri, ba a ajiye komai ba. Ina auna solar daga kayan aiki kawai, ba shawarar waya, lafiya, kuɗi, ko doka ba. Kiyasi kawai."},
"ig": {"why": "Gịnị mere Naija Solar? Ọtụtụ ụlọ na ụlọ ahịa na Naịjirịa na-enwe nkwụsị ọkụ kwa ụbọchị ma na-emefu ego na mmanụ. Ịkpọ ọkachamara solar nwere ike iwe oge na ego, ya mere ọtụtụ mmadụ na-eche echiche ma ọ bụ na-akwụ karịa. Naija Solar ga-enye gị atụmatụ mbụ n'efu n'asụsụ gị: kwuo, dee, ma ọ bụ see foto ngwa gị.",
"steps": [("🗣️", "1. Họrọ asụsụ gị", "Pịa asụsụ n'elu."),
("🎤", "2. Kwuo, dee, ma ọ bụ see foto", "Jiri olu, igbe ederede, ma ọ bụ foto ise."),
("🏡", "3. Hụ ụlọ gị, nụ atụmatụ", "Ụlọ gị, usoro ahụ, ọnụahịa, a ga-akpọ ya.")],
"privacy": "🔒 Nzuzo, ọ dịghị ihe echekwara. Ana m atụ solar naanị site na ngwa, ọ bụghị ndụmọdụ waya, ahụike, ego, ma ọ bụ iwu. Naanị atụmatụ."},
}
def steps_html(lang="en"):
g = GUIDE_TEXT.get(lang, GUIDE_TEXT["en"])
cards = "".join('
%s
%s
'
'
%s
' % s for s in g["steps"])
return ('
%s
%s
%s
'
% (g["why"], cards, g["privacy"]))
GUIDE_TITLE = {"en": "New here? How Naija Solar works", "pcm": "You new here? How Naija Solar dey work",
"yo": "Tuntun níbí? Bí Naija Solar ṣe ń ṣiṣẹ́", "ha": "Sabo ne? Yadda Naija Solar ke aiki",
"ig": "Ọ̀hụrụ ebe a? Otú Naija Solar si arụ ọrụ"}
def hero_html(lang):
u = UI.get(lang, UI["en"])
return (f'
'
f'
{u["title"]}
{u["sub"]}
')
# ── handlers ──────────────────────────────────────────────────────────────────
def _sel_to_df(sel):
return [[k, v] for k, v in sel.items()]
def _df_to_sel(df):
rows = df.values.tolist() if hasattr(df, "values") else (df or [])
out = {}
for row in rows:
try:
if str(row[0]).strip() in data.APPLIANCES and int(float(row[1])) > 0:
out[str(row[0]).strip()] = int(float(row[1]))
except (ValueError, IndexError, TypeError):
pass
return out
def get_session(s):
return s if s is not None else sessions.start("Solar")
def _psh(state, geolat):
return locations.psh_from_lat(geolat) if (geolat or "").strip() else locations.psh_for_state(state)
HIDE, SHOW = gr.update(visible=False), gr.update(visible=True)
def run(audio, text, state, geolat, uilang, sess):
sess = get_session(sess)
src, outlang = (text or ""), uilang
if audio:
try:
src = asr.transcribe(audio)
if uilang == "en": # respect an explicit language choice; auto-detect only from the default
outlang = detect_language(src)
sess.event("asr", transcript=src)
except Exception:
return gr.update(), gr.update(), gr.update(), "", gr.update(), "Could not hear that. Try again or type.", sess, None, uilang, HIDE, gr.update()
sel = extract(src)
sess.event("input", text=src, lang=outlang, found=len(sel))
if not sel:
msg = GUARD.get(outlang, GUARD["en"]) if (src or "").strip() else ""
return gr.update(), gr.update(), gr.update(), "", gr.update(), msg, sess, None, outlang, HIDE, gr.update()
r = engine.size(sel, _psh(state, geolat))
sess.event("sized", kwh=r["daily_kwh"], panels=r["panel"]["count"], cost=r["batteries"]["durable"]["total"])
content = chips_html(sel) + stat_tiles(r) + rec_cards(r)
return (_house_data(r, sel),
system_view(r), load_breakdown_html(sel), content, applist_html(_sel_to_df(sel)), "", sess, r, outlang, SHOW, _sel_to_df(sel))
# Persistent on /data so cached audio survives Space restarts (was ./traces, which is wiped on
# every rebuild, so the slow first-generation was paid again after each restart).
_TTS_CACHE = str(_DATA_DIR / "tts_cache")
# Bump this whenever the voice itself changes (e.g. MMS -> SoroTTS). It is part of the cache key,
# so old audio rendered by a previous voice is never reused. Without it, a cached Yoruba clip from
# the old MMS voice would keep playing for the same plan even after the voice was upgraded.
_TTS_VERSION = os.environ.get("TTS_CACHE_VERSION", "sorotts-3")
_TTS_CACHE_KEEP = int(os.environ.get("TTS_CACHE_KEEP", "800")) # cap clips on disk (LRU eviction)
def _prune_tts_cache(keep=None):
"""Keep the audio cache bounded on the persistent disk: evict the least-recently-used clips."""
keep = _TTS_CACHE_KEEP if keep is None else keep
try:
files = [os.path.join(_TTS_CACHE, f) for f in os.listdir(_TTS_CACHE) if f.endswith(".wav")]
if len(files) <= keep:
return
files.sort(key=os.path.getmtime) # oldest (least recently used) first
for p in files[:len(files) - keep]:
try:
os.remove(p)
except OSError:
pass
except OSError:
pass
def speak(r, lang, sess):
if not r:
return None, sess
sess = get_session(sess)
words = narrate(r, lang)
# The narration is deterministic, so identical (result + language) reuses the audio.
os.makedirs(_TTS_CACHE, exist_ok=True)
key = hashlib.md5(("%s|%s|%s" % (_TTS_VERSION, lang, words)).encode("utf-8")).hexdigest()
path = os.path.join(_TTS_CACHE, key + ".wav")
if os.path.exists(path) and os.path.getsize(path) > 1200:
try:
os.utime(path, None) # mark recently used so it is kept (LRU)
except OSError:
pass
sess.event("walkthrough", lang=lang, cached=True)
return path, sess
audio = None
try:
audio = tts.speak(_for_tts(words), lang=lang, out_path=path)
_prune_tts_cache()
except Exception:
audio = None
sess.event("walkthrough", lang=lang, cached=False)
return audio, sess
def recalc(df, state, geolat, sess):
sess = get_session(sess)
sel = _df_to_sel(df)
if not sel:
return gr.update(), gr.update(), gr.update(), "", "Add an appliance.", sess, None, HIDE
r = engine.size(sel, _psh(state, geolat))
return (_house_data(r, sel),
system_view(r), load_breakdown_html(sel), chips_html(sel) + stat_tiles(r) + rec_cards(r),
"", sess, r, SHOW)
def from_photos(files, camera, state, geolat, uilang, sess):
"""Up to 5 uploaded photos or a camera snap: the vision model lists the appliances."""
sess = get_session(sess)
paths = []
if camera:
paths.append(camera if isinstance(camera, str) else getattr(camera, "name", None))
for f in (files or []):
paths.append(f if isinstance(f, str) else getattr(f, "name", None))
paths = [p for p in paths if p][:5]
if not paths:
return gr.update(), gr.update(), gr.update(), "", [], "Open the camera or add a photo of your appliances.", sess, None, uilang, HIDE
seen = {}
for path in paths:
try:
desc = vision.describe(path, "List every household electrical appliance you can see, with counts. "
"Use simple names like fridge, fan, bulb, TV, AC, freezer, laptop.")
sess.event("vision", desc=desc[:140])
for k, v in extract(desc).items():
seen[k] = seen.get(k, 0) + v
except Exception:
pass
if not seen:
return gr.update(), gr.update(), gr.update(), "", gr.update(), "Could not spot appliances. Try a clearer photo or type them.", sess, None, uilang, HIDE, gr.update()
r = engine.size(seen, _psh(state, geolat))
sess.event("sized_photos", panels=r["panel"]["count"])
return (_house_data(r, seen),
system_view(r), load_breakdown_html(seen), chips_html(seen) + stat_tiles(r) + rec_cards(r),
applist_html(_sel_to_df(seen)), "Detected from your photo. Open 'Adjust appliances' to select or fix.", sess, r, uilang, SHOW, _sel_to_df(seen))
def applist_html(rows):
"""Render the current appliance list as chips (HTML always re-renders reliably)."""
if not rows:
return '
No appliances yet. Pick one above and tap Add.
'
chips = "".join(
'%s %s ×%s' % (
EMOJI.get(r[0], "🔌"), str(r[0]).split(" (")[0], r[1]) for r in rows)
return '
%s
' % chips
def add_appliance(name, qty, cur):
"""Add or update one appliance in the state-backed list (the reliable source of truth)."""
rows = [list(x) for x in (cur or [])]
if not name:
return rows, applist_html(rows)
try:
q = max(1, min(99, int(float(qty or 1))))
except (ValueError, TypeError):
q = 1
for r in rows:
if str(r[0]).strip().lower() == str(name).strip().lower():
r[1] = q
break
else:
rows.append([name, q])
return rows, applist_html(rows)
def clear_appliances():
return [], applist_html([])
def set_lang(code):
u = UI.get(code, UI["en"])
b = LABELS.get(code, LABELS["en"])
return (code, hero_html(code), gr.update(placeholder=u["type"]),
f'
{u["fine"]}
',
'
🎤 %s tap Record, speak, then Stop, it sizes itself
') % (
r["panel"]["count"], r["inverter"]["kva"], d["count"], format(d["total"], ","))
return summary, home_2d_svg(r), count_html()
_QA_CACHE = {}
def ask(question, r, lang, sess):
"""Answer a follow-up question about the sized system (cached so repeats are instant)."""
sess = get_session(sess)
q = (question or "").strip()
if not q:
return "Ask about your plan, like 'why these panels?' or 'what would an AC add?'", sess
if not r:
return "Size your appliances first, then I can answer questions about your plan.", sess
key = hashlib.md5(("%s|%s|%s" % (q, _house_data(r, None), lang)).encode("utf-8")).hexdigest()
if key in _QA_CACHE:
sess.event("qa", q=q[:60], cached=True)
return _QA_CACHE[key], sess
d = r["batteries"]["durable"]
ctx = ("Daily use %s kWh. Plan: %d %s (about %s W array), a %s kVA inverter, %d %s with %s kWh backup, "
"total about %s naira, at %s peak-sun hours." % (
r["daily_kwh"], r["panel"]["count"], r["panel"]["name"], r["array_w"], r["inverter"]["kva"],
d["count"], d["name"], d["backup_kwh"], format(d["total"], ","), r["sun_hours"]))
if config.is_mock():
ans = "Connect a model (env/solar.sh) and I will answer using your plan above."
else:
# Qwen3-1.7B writes fluent English and readable Pidgin, but garbles free-form Yoruba/Hausa/Igbo.
# Answer those questions in clear English so the advice is always usable; the spoken plan and the
# whole interface stay in the chosen language, so the experience is still theirs.
ans_lang = lang if lang in ("en", "pcm") else "en"
prompt = (
"You are a warm, practical Nigerian solar advisor talking to a homeowner about their solar plan.\n"
"Their plan: %s\n"
"Answer their question in 2 to 4 short, friendly sentences in %s. Be concrete and encouraging, and "
"use the plan's numbers when they help. You can help with anything about their plan, the panels, "
"batteries, the inverter, sunlight and weather, cost and savings, running or adding specific "
"appliances later, comparing solar with a generator, maintenance, or general home solar and "
"electricity advice for Nigeria. Only if the question is clearly nothing to do with energy, power, "
"money or the home, reply in one short friendly line and invite them to ask about their solar setup. "
"Question: %s /no_think" % (ctx, LANG_NAME.get(ans_lang, "English"), q))
# The small model very occasionally returns an empty turn (only think-tags, or a truncated
# generation). Retry once, and never hand back a blank, fall back to a useful plan summary.
ans = ""
for _ in range(2):
try:
ans = _clean(llm.complete(prompt, max_tokens=230, timeout=190, model="Qwen/Qwen3-1.7B")) # Qwen answers follow-up questions
except Exception:
ans = ""
if len(ans) >= 8:
break
if len(ans) < 8:
ans = ("Here is the short version: your plan is %d %s (about %s W of panels), a %s kVA inverter and "
"%d %s for backup, around %s naira in total. Ask me anything about adding appliances, how long "
"it runs, your savings, or where to buy." % (
r["panel"]["count"], r["panel"]["name"], r["array_w"], r["inverter"]["kva"],
d["count"], d["name"], format(d["total"], ",")))
sess.event("qa", q=q[:60], cached=False, empty=True)
return ans, sess
_QA_CACHE[key] = ans
sess.event("qa", q=q[:60], cached=False)
return ans, sess
APPLIANCE_ABBR = {
"Fridge (small)": "fridge", "Chest freezer": "freezer", "Ceiling fan": "cfan", "Standing fan": "fan",
'TV (32–43" LED)': "tv", "TV (large)": "tv", "Decoder (DStv/GOtv)": "decoder", "Sound system": "sound",
"LED bulb": "bulb", "Energy-saver bulb": "bulb", "Security light": "bulb", "Laptop": "laptop", "Desktop PC": "desktop",
"Air conditioner (1HP)": "ac", "Air conditioner (1.5HP)": "ac", "Air conditioner (2HP)": "ac",
"Microwave": "micro", "Washing machine": "wash", "Water pump (1HP)": "pump", "Water pump (0.5HP)": "pump",
"Electric iron": "iron", "Electric kettle": "kettle", "Water heater": "heater", "Phone charger": "phone", "Wifi router": "wifi",
}
def _house_data(r, sel=None):
appl = ",".join("%s:%d" % (APPLIANCE_ABBR.get(k, "box"), v) for k, v in (sel or {}).items())
return "%d|%d|%s" % (r["panel"]["count"], r["batteries"]["durable"]["count"], appl)
# Three.js loaded in the page (Gradio doesn't sanitize head), drawing a real 3D
# flat-roof home into a persistent canvas via renderHouse(panels, batteries).
THREE_HEAD = """
"""
RENDER_JS = "(s) => { if(s){ var a=(''+s).split('|'); if(window.renderHouse){ window.renderHouse(parseInt(a[0])||0, parseInt(a[1])||0, a[2]||''); } } }"
# ── home extras: stats strip, model wall, reviews wall, and accounts ──────────
_VALID_LANGS = ("en", "pcm", "yo", "ha", "ig")
def _esc(s):
return str(s or "").replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
def stats_html():
return ('
🌍 5 languages'
'🧠 4 models, all under 4B'
'🔋 free & open · scales to zero
')
def models_html():
cards = [
("", "Understands you & answers", "Qwen3-1.7B", "1.7B", ""),
("", "Hears your speech", "Whisper small", "0.24B", ""),
("ours", "Speaks your language", "SoroTTS ↗", "3B · our fine-tune", "https://huggingface.co/Shinzmann/sorotts"),
("bmb", "Reads your photos", "MiniCPM-V-2 ↗", "3.4B · OpenBMB", "https://huggingface.co/openbmb/MiniCPM-V-2"),
]
out = []
for cls, role, name, params, href in cards:
tag = ("mw " + cls).strip()
inner = ('
' % "".join(out)
def _stars(n):
n = max(1, min(5, int(n or 5)))
return "★" * n + "☆" * (5 - n)
def reviews_html():
try:
real = store.STORE.list_testimonials(60)
except Exception:
real = []
items, seen = [], set()
for t in real:
txt = (t.get("text") or "").strip()
if not txt:
continue
k = ((t.get("name") or "").strip().lower(), txt.lower())
if k in seen:
continue
seen.add(k)
items.append(t)
if not items:
return ('
No reviews yet. '
'Be the first to share your experience, just below.
')
cards = []
for t in items[:24]:
lang = t.get("lang", "en")
tag = ('in %s' % LANG_NAME.get(lang, "")) if lang and lang != "en" else ""
cards.append('
' % "".join(cards)
def reviews_title():
try:
n = len([t for t in store.STORE.list_testimonials(60) if (t.get("text") or "").strip()])
except Exception:
n = 0
sub = ("%d review%s from people who used it" % (n, "" if n == 1 else "s")) if n else "be the first to leave one below"
return ('
💬What people are saying'
'%s
' % sub)
# ── accounts ──────────────────────────────────────────────────────────────────
def _rec_history(sel, r, state, lang):
return {"appliances": sel, "panels": r["panel"]["count"], "kva": r["inverter"]["kva"],
"kwh": r["daily_kwh"], "cost": r["batteries"]["durable"]["total"], "state": state, "lang": lang,
"label": ", ".join("%d %s" % (v, k.split(" (")[0]) for k, v in list(sel.items())[:4])}
def acc_greeting(user):
if not user:
return ""
n = user.get("count", 0)
return ('
👋 Welcome, %s · %d saved sizing%s. '
'New sizings are saved here automatically.
'
% (_esc(user.get("name", "")), n, "" if n == 1 else "s"))
def acc_history_html(user):
if not user:
return ""
try:
recs = store.STORE.list_sizings(user["email"])
except Exception:
recs = []
if not recs:
return '
No saved sizings yet. Size a system and it lands here.
'
rows = []
for rec in recs[:20]:
when = time.strftime("%d %b %Y", time.localtime(rec.get("ts", 0))) if rec.get("ts") else ""
rows.append('
' % ("ok" if ok else "err", _esc(text))
def do_signup(email, pw, name):
user, err = store.STORE.create_user(email, pw, name)
if err:
return None, _accmsg(err, False), SHOW, HIDE, "", ""
return user, _accmsg("Account created, welcome to Naija Solar!", True), HIDE, SHOW, acc_greeting(user), acc_history_html(user)
def do_login(email, pw):
user, err = store.STORE.login(email, pw)
if err:
return None, _accmsg(err, False), SHOW, HIDE, "", ""
return user, _accmsg("Signed in.", True), HIDE, SHOW, acc_greeting(user), acc_history_html(user)
def do_logout():
return None, "", SHOW, HIDE, "", ""
def save_after_size(r, df, user, state, lang):
"""When a signed-in user sizes a system, save it to their history and refresh the account panel."""
if user and r:
try:
sel = _df_to_sel(df)
if sel:
store.STORE.add_sizing(user["email"], _rec_history(sel, r, state, lang))
user = store.STORE.get(user["email"]) or user # refresh the saved count
except Exception:
pass
return (acc_greeting(user) if user else ""), (acc_history_html(user) if user else ""), user
def post_review(name, stars, text, user, lang):
"""A visitor posts a public review (stars + words). Refreshes the wall and clears the box."""
text = (text or "").strip()
if len(text) < 4:
return _accmsg("Please write a few words about your experience.", False), gr.update(), gr.update(), gr.update()
who = (name or "").strip() or (user.get("name") if user else "") or "A Naija Solar user"
try:
store.STORE.add_testimonial({"name": who[:40], "text": text[:240],
"rating": int(stars or 5), "lang": lang if lang in _VALID_LANGS else "en"})
except Exception:
pass
return _accmsg("Thank you, your review is now live below.", True), reviews_title(), reviews_html(), ""
def build():
with gr.Blocks(title="Naija Solar", analytics_enabled=False) as demo: # css/theme/head are applied at launch()/mount in Gradio 6
sess, result, lang = gr.State(None), gr.State(None), gr.State("en")
auth_state = gr.State(None)
gr.HTML(logo_html())
usercount = gr.HTML(count_html(), elem_id="ucount")
with gr.Accordion("👤 Account, sign in to save your sizings", open=False):
acc_msg = gr.HTML()
with gr.Column(visible=True) as acc_out:
with gr.Row():
acc_email = gr.Textbox(placeholder="you@email.com", show_label=False, scale=2, elem_classes="typein")
acc_pass = gr.Textbox(placeholder="password (6+ chars)", type="password", show_label=False, scale=2, elem_classes="typein")
acc_name = gr.Textbox(placeholder="your name (only needed for a new account)", show_label=False, elem_classes="typein")
with gr.Row():
login_btn = gr.Button("Sign in", elem_classes="gobtn", scale=1)
signup_btn = gr.Button("Create account", variant="primary", elem_classes="gobtn", scale=1)
with gr.Column(visible=False) as acc_in:
acc_greet = gr.HTML()
gr.HTML('
')
with gr.Row():
text = gr.Textbox(placeholder=UI["en"]["type"], show_label=False, container=False,
lines=1, scale=4, elem_classes="typein")
text_btn = gr.Button("Size it", variant="primary", scale=1, min_width=96, elem_classes="gobtn")
gr.HTML('
✨ New here? Tap an example a full result in one click, no mic or typing
')
with gr.Row(elem_classes="exrow"):
ex_btns = [gr.Button(lbl, elem_classes="exbtn", size="sm") for lbl, _ in EXAMPLES]
mode_p = gr.HTML('
📷 Or snap / upload photos
')
with gr.Row(equal_height=True):
photos = gr.File(file_count="multiple", file_types=["image"], scale=1,
label="Upload up to 5 photos", elem_classes="photofile")
camera = gr.Image(sources=["webcam"], type="filepath", scale=1,
label="Open camera", height=216, elem_classes="camerabox")
photo_btn = gr.Button("Detect appliances from photo", variant="primary", elem_classes="gobtn")
status = gr.Markdown("", elem_classes="status")
with gr.Tabs():
with gr.Tab("🏠 3D Home"):
house = gr.HTML('
🏠 Your home in 3D '
'drag to look around · scroll to zoom
')
with gr.Accordion("Adjust appliances (add or edit manually)", open=False):
gr.HTML('
Pick an appliance and quantity, then tap Add. Add as many as you like, '
'then tap Update sizing.
')
with gr.Row():
picker = gr.Dropdown(list(data.APPLIANCES), multiselect=False, value=None, filterable=True,
label="Appliance", scale=3, elem_classes="addbar")
qty_in = gr.Number(value=1, minimum=1, maximum=99, step=1, label="Qty", scale=1)
add_btn = gr.Button("➕ Add", variant="primary", elem_classes="gobtn", scale=1)
applist = gr.HTML(elem_classes="applist")
appl_state = gr.State([])
with gr.Row():
recalc_btn = gr.Button("⚡ Update sizing", variant="primary", elem_classes="gobtn", scale=3)
clear_btn = gr.Button("Clear list", scale=1)
with gr.Accordion("💬 Ask about your system", open=False):
qa_in = gr.Textbox(placeholder="e.g. Why these panels? What would an AC add? How long does the battery last?",
show_label=False, container=False, lines=1, elem_classes="typein")
qa_btn = gr.Button("Ask", variant="primary", elem_classes="gobtn")
qa_out = gr.Markdown()
with gr.Accordion("Where to buy", open=False):
gr.HTML(vendors_html())
with gr.Accordion("Activity", open=False):
sess_btn = gr.Button("Refresh", size="sm")
sess_html = gr.HTML()
with gr.Accordion("💛 Was this helpful? Get solar updates", open=False, elem_classes="fbacc"):
fb_comment = gr.Textbox(placeholder="Any comment to help us improve? (optional)", show_label=False,
lines=2, elem_classes="typein")
with gr.Row():
fb_up = gr.Button("👍 Helpful", elem_classes="fbbtn")
fb_down = gr.Button("👎 Could be better", elem_classes="fbbtn")
fb_msg = gr.HTML()
gr.HTML('
📩 Want solar tips, or your result saved? Add your email, optional, no spam.