Israelbliz commited on
Commit
c7c92fb
·
verified ·
1 Parent(s): 7cf2a3d

Upload 15 files

Browse files
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.xlsx
4
+ *.docx
5
+ .DS_Store
DEVELOPER_NOTES.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PUE Report Agent
2
+
3
+ Turns a DREEF **Productive Use of Energy (PUE) master-sheet** workbook into a
4
+ community PUE report, using LangChain for the *prose* and deterministic Python
5
+ for *every number*.
6
+
7
+ This skeleton was reverse-engineered from two real report/master-sheet pairs
8
+ (Jaji and Kwarua Tasha, both Kaduna State). It already extracts cleanly from
9
+ both.
10
+
11
+ ---
12
+
13
+ ## Why it's split the way it is
14
+
15
+ The single most important design decision: **the LLM never touches a number.**
16
+
17
+ When a report's figures are re-typed by a language model, they drift — that is
18
+ literally how one of the source reports ended up with budget and equipment
19
+ numbers that didn't match its own master sheet. So the architecture is:
20
+
21
+ ```
22
+ master.xlsx
23
+
24
+ ▼ extractors.py (pure Python, openpyxl — deterministic)
25
+ PUEReportData
26
+
27
+ ▼ calc.py (recompute every total from line items)
28
+ PUEReportData (correct totals)
29
+
30
+ ▼ validate.py (reconciliation; appends warnings)
31
+ PUEReportData (+warnings)
32
+
33
+ ├─► agent.py generate_narratives() LLM — PROSE ONLY
34
+ │ • Community Overview ............ Gemini (gemini-1.5-pro)
35
+ │ • Demographic narrative ......... Gemini
36
+ │ • Processing Insights ........... Gemini
37
+
38
+ ▼ render.py → template-faithful .docx
39
+ ```
40
+
41
+ * **`schema.py`** — Pydantic models. The contract between every stage.
42
+ * **`extractors.py`** — reads the workbook into the schema. Numbers come from
43
+ here, never from an LLM. Budget extraction is header-aware (each section maps
44
+ its own columns), so irregular blocks like "OTHERS" parse correctly.
45
+ * **`calc.py`** — recomputes every total from its line items: budget section
46
+ totals, BOQ grand total, equipment kW/quantity totals, energy projections,
47
+ financial-model monthly totals. The template shipped with a Grand Total of
48
+ ₦30,756,000 when its line items summed to ₦37,256,000; the agent never
49
+ reproduces that kind of error, and records any disagreement as a warning.
50
+ * **`validate.py`** — checks interview counts agree, flags stale template text.
51
+ * **`templates.py`** — the boilerplate sections, verbatim from the reference
52
+ template (introduction, methodology, infrastructure specs, governance, safety,
53
+ conditions for scale-up, MER, risks, SDGs, challenges, AI disclosure), filled
54
+ with `str.format`. Never sent to a model.
55
+ * **`agent.py`** — the only LLM code. All three narratives (Community Overview,
56
+ demographic, processing insights) are generated with **Gemini**
57
+ (`gemini-1.5-pro`). The model is swappable per call, so individual chains can
58
+ move to another provider later without changing the chains.
59
+ * **`render.py`** — produces a Word document that mirrors the reference
60
+ template's structure exactly.
61
+
62
+ ## What `render.py` reproduces from the template
63
+
64
+ * **Title page** in the template's layout.
65
+ * **Auto Table of Contents** — a real Word TOC field that builds and renumbers
66
+ itself on open.
67
+ * **Auto List of Tables** — a Word Table-of-Figures field keyed to the "Table"
68
+ caption sequence.
69
+ * **SEQ-field caption numbering** — every caption is `Table ` + a `SEQ Table`
70
+ field (or `Figure ` + `SEQ Figure`). Word computes the numbers, so they are
71
+ always sequential and correct no matter how many tables a community needs —
72
+ nothing is hard-numbered.
73
+ * **Every section in template order** with the template's headings and tone.
74
+ * **Community-adaptive tables** — one machinery table per processed crop, one
75
+ equipment table per section, one budget table per section, each with a
76
+ recomputed TOTAL row. The skeleton stays identical; the rows follow the data.
77
+ * Fields are set to **update on open**, so the TOC, List of Tables and all
78
+ caption numbers populate the first time the file is opened in Word.
79
+ (LibreOffice's headless PDF convert doesn't run that update — open in Word, or
80
+ press Ctrl+A then F9, to populate them.)
81
+
82
+ ---
83
+
84
+ ## Quick start
85
+
86
+ ```bash
87
+ pip install -r requirements.txt
88
+
89
+ # Extraction + recompute + validation + render — no API key needed.
90
+ # Narratives are skipped with --no-llm, but the full template still renders:
91
+ python run.py "Jaji-_Kaduna_PUE_Master_Sheet.xlsx" --no-llm \
92
+ --developer "Green Edge Consortium" --date "February 2026" \
93
+ --solar-pv "6.45 MWp" --battery "10 MWh" \
94
+ --docx-out jaji_report.docx
95
+
96
+ # Full run with AI narratives (all narratives use Gemini):
97
+ export GOOGLE_API_KEY=...
98
+ python run.py "Jaji-_Kaduna_PUE_Master_Sheet.xlsx" \
99
+ --developer "Green Edge Consortium" --date "February 2026" \
100
+ --docx-out jaji_report.docx --json-out jaji.json
101
+ ```
102
+
103
+ Open the resulting `.docx` in Word; the Table of Contents, List of Tables and
104
+ all caption numbers populate automatically on first open.
105
+
106
+ ---
107
+
108
+ ## What the data layers in the master sheet mean
109
+
110
+ | Layer | Sheets | Role |
111
+ |-------|--------|------|
112
+ | Raw survey | `Minigrid Farmers Input`, `Processors`, `SME`, `E-Mobility`, `Market`, `ESG` | One row per respondent |
113
+ | Analysis | `* Analysis Sheet` | Pre-aggregated label/value blocks |
114
+ | **Key Findings** | `Key Findings` | Report-ready tables (the extractor's main source) |
115
+ | Equipment / Budget / Finance | `Proposed * PUE Equipment`, `Budget`, `Individual Model`, `Agrohub Model`, `Summary` | Plans & costs |
116
+
117
+ The extractor prefers raw/analysis sheets for survey facts and only uses Key
118
+ Findings for **developer-supplied** figures (capacities, revenue-per-user,
119
+ projected impact) that aren't derivable from survey data — because Key Findings
120
+ is where stale template text from other communities tends to survive.
121
+
122
+ ### Things that are NOT in the survey data
123
+ * Mini-grid capacities (Solar PV / Battery / Annual Consumption) — developer input.
124
+ * Revenue-per-user table — developer input, often shared across communities.
125
+ Pass these in explicitly; the validator warns if they're missing.
126
+
127
+ ---
128
+
129
+ ## Extending to a new community / new sheet layout
130
+
131
+ 1. Run `python run.py new_sheet.xlsx --no-llm`.
132
+ 2. Read the warnings and eyeball the summary against the source.
133
+ 3. If a block comes back empty, the label probably moved. The extractor finds
134
+ blocks by **label text scanned across all columns** (`_label_col`) rather
135
+ than fixed cell addresses, so usually only the label string needs updating.
136
+ 4. Add a `validate.py` check for any new invariant you discover.
137
+
138
+ ## Adapting to a community whose data is shaped differently
139
+
140
+ The renderer is data-driven, so new crops, new SME types, more or fewer budget
141
+ sections, etc. flow through automatically — the template skeleton stays fixed
142
+ while the rows follow the master sheet. The two things to supply per community
143
+ that are NOT in the survey data:
144
+
145
+ * **Mini-grid / micro capacities** (Solar PV, battery, annual consumption,
146
+ distribution metrics) — developer figures; pass via the CLI flags or set
147
+ `data.minigrid` / `data.microgrid` before rendering.
148
+ * **Report date** for the title page (`--date`).
149
+
150
+ ---
151
+
152
+ ## Tested against
153
+ * `Jaji-_Kaduna_PUE_Master_Sheet.xlsx` — clean extraction, 3 advisory warnings.
154
+ * `Kwarua_Tasha_-_Kaduna_PUE_Master_Sheet.xlsx` — clean extraction, 6 warnings
155
+ correctly flagging real defects in that workbook.
README.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PUE Report Generator
3
+ emoji: 📄
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: streamlit
7
+ sdk_version: 1.40.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # PUE Report Generator
13
+
14
+ Upload a community **PUE master-sheet** workbook and download a
15
+ template-faithful Word report. Every figure in the report is recomputed
16
+ deterministically from the sheet — the (optional) AI step only ever rewrites
17
+ prose, never numbers.
18
+
19
+ ## Use it
20
+
21
+ 1. Upload the master sheet (`.xlsx`).
22
+ 2. Set the developer name, report date, and any developer-supplied mini-grid
23
+ capacities (only if they aren't already in the sheet).
24
+ 3. Click **Generate report** and download the `.docx`.
25
+
26
+ ## Pipeline
27
+
28
+ ```
29
+ master.xlsx --extract--> PUEReportData --recompute--> --validate--> (+warnings)
30
+ |
31
+ +-- generate_narratives (LLM, prose only)
32
+ +-- render (deterministic .docx)
33
+ ```
34
+
35
+ ## AI narratives (optional)
36
+
37
+ The three prose sections can be written by Gemini. To enable them, add a
38
+ `GOOGLE_API_KEY` secret to the Space (Settings -> Variables and secrets) and
39
+ switch on the toggle in the sidebar. Without a key, those sections use a plain
40
+ deterministic fallback and everything else works unchanged.
41
+
42
+ ## Run locally
43
+
44
+ ```bash
45
+ pip install -r requirements.txt
46
+ streamlit run app.py
47
+ ```
48
+
49
+ (Developer/internals notes are in `DEVELOPER_NOTES.md`.)
app.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streamlit front-end for the DREEF PUE report generator.
3
+
4
+ Upload a community master-sheet workbook, set the developer / date (and any
5
+ externally-supplied mini-grid capacities), and download a template-faithful
6
+ Word report. Optional AI narratives use Gemini and require GOOGLE_API_KEY.
7
+
8
+ Run locally: streamlit run app.py
9
+ Deploy: push this folder to a Hugging Face Space (SDK = Streamlit).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from io import BytesIO
16
+
17
+ import streamlit as st
18
+
19
+ from pue_report_agent import extract_master_sheet, recompute, validate
20
+
21
+ st.set_page_config(page_title="PUE Report Generator", page_icon="📄",
22
+ layout="centered")
23
+
24
+ st.title("📄 PUE Report Generator")
25
+ st.caption(
26
+ "Turn a community PUE master-sheet workbook into a template-faithful Word "
27
+ "report. Every number is recomputed from the sheet — the AI only ever "
28
+ "touches prose, never figures."
29
+ )
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Sidebar — options
33
+ # --------------------------------------------------------------------------- #
34
+ with st.sidebar:
35
+ st.header("Report settings")
36
+ developer = st.text_input("Mini-grid developer",
37
+ value="the mini-grid developer")
38
+ report_date = st.text_input("Report date (title page)", value="")
39
+
40
+ st.subheader("Mini-grid capacities")
41
+ st.caption(
42
+ "Developer-supplied; only fill these if they aren't already in the "
43
+ "sheet's Key Findings."
44
+ )
45
+ solar_pv = st.text_input("Solar PV", value="", placeholder="e.g. 6.45 MWp")
46
+ battery = st.text_input("Battery storage", value="", placeholder="e.g. 10 MWh")
47
+ annual_consumption = st.text_input("Annual consumption", value="",
48
+ placeholder="e.g. 5 MW")
49
+
50
+ st.subheader("AI narratives")
51
+
52
+ def _has_api_key() -> bool:
53
+ if os.environ.get("GOOGLE_API_KEY"):
54
+ return True
55
+ try:
56
+ return bool(st.secrets.get("GOOGLE_API_KEY"))
57
+ except Exception:
58
+ return False
59
+
60
+ has_key = _has_api_key()
61
+ want_narratives = st.toggle(
62
+ "Generate AI prose sections (Gemini)", value=False,
63
+ help="Needs GOOGLE_API_KEY in the Space secrets or environment. "
64
+ "Without it, the three prose sections use a plain fallback.")
65
+ if want_narratives and not has_key:
66
+ st.warning("No GOOGLE_API_KEY found — narratives will be skipped.")
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Main — upload & generate
70
+ # --------------------------------------------------------------------------- #
71
+ uploaded = st.file_uploader(
72
+ "Upload the PUE master sheet (.xlsx)", type=["xlsx", "xlsm"])
73
+
74
+ if uploaded is None:
75
+ st.info("Upload a master-sheet workbook to begin.")
76
+ st.stop()
77
+
78
+ if st.button("Generate report", type="primary"):
79
+ with st.spinner("Extracting, recomputing, validating…"):
80
+ # extract_master_sheet expects a path, so spill the upload to a temp file.
81
+ with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
82
+ tmp.write(uploaded.getvalue())
83
+ xlsx_path = tmp.name
84
+ try:
85
+ data = extract_master_sheet(xlsx_path)
86
+ data = recompute(data)
87
+
88
+ # Merge any developer-supplied capacity overrides.
89
+ if solar_pv:
90
+ data.minigrid.solar_pv = solar_pv
91
+ if battery:
92
+ data.minigrid.battery_storage = battery
93
+ if annual_consumption:
94
+ data.minigrid.annual_consumption = annual_consumption
95
+
96
+ data = validate(data)
97
+
98
+ narrative_note = None
99
+ if want_narratives:
100
+ from pue_report_agent import generate_narratives
101
+ if generate_narratives is None:
102
+ narrative_note = ("langchain isn't installed — prose "
103
+ "sections used the fallback.")
104
+ elif not has_key:
105
+ narrative_note = ("No API key — prose sections used the "
106
+ "fallback.")
107
+ else:
108
+ data = generate_narratives(data)
109
+
110
+ # Render to a temp .docx and read the bytes back for download.
111
+ from pue_report_agent.render import render
112
+ out_path = os.path.join(tempfile.gettempdir(),
113
+ "PUE_Report.docx")
114
+ render(data, out_path, developer=developer,
115
+ report_date=report_date)
116
+ with open(out_path, "rb") as fh:
117
+ docx_bytes = fh.read()
118
+ finally:
119
+ try:
120
+ os.unlink(xlsx_path)
121
+ except OSError:
122
+ pass
123
+
124
+ st.success(f"Report generated for {data.community.name or 'the community'}.")
125
+ if narrative_note:
126
+ st.info(narrative_note)
127
+
128
+ fname = f"{(data.community.name or 'Community').replace(' ', '_')}_PUE_Report.docx"
129
+ st.download_button(
130
+ "⬇️ Download Word report", data=docx_bytes, file_name=fname,
131
+ mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
132
+ type="primary")
133
+
134
+ # Quick at-a-glance summary.
135
+ col1, col2, col3 = st.columns(3)
136
+ col1.metric("Interviewed", data.interviews.total)
137
+ col2.metric("Machines", len(data.processors.machinery))
138
+ col3.metric("Equipment cost (₦)",
139
+ f"{(data.budget.grand_total or 0):,.0f}")
140
+
141
+ if data.warnings:
142
+ with st.expander(f"⚠️ {len(data.warnings)} item(s) to review", expanded=True):
143
+ for w in data.warnings:
144
+ st.write("• " + w)
145
+ else:
146
+ st.caption("No validation warnings.")
pue_report_agent/__init__.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pue_report_agent
3
+ ================
4
+ Turn a DREEF PUE master-sheet workbook into a community PUE report.
5
+
6
+ Pipeline:
7
+ master.xlsx ──extract──► PUEReportData ──validate──► (+warnings)
8
+
9
+ ├──generate_narratives (LLM, prose only)
10
+ └──render (deterministic .docx)
11
+
12
+ Public API:
13
+ extract_master_sheet(path) -> PUEReportData (no LLM)
14
+ validate(data) -> PUEReportData (adds warnings)
15
+ generate_narratives(data, llm=None) -> PUEReportData (LLM prose)
16
+ build_report_data(path, developer) -> PUEReportData (end-to-end)
17
+ """
18
+
19
+ from .schema import PUEReportData
20
+ from .extractors import extract_master_sheet
21
+ from .validate import validate
22
+ from .calc import recompute
23
+
24
+ # agent imports langchain; keep it optional so extraction works without it.
25
+ try:
26
+ from .agent import generate_narratives, build_report_data # noqa: F401
27
+ except ImportError: # langchain not installed
28
+ generate_narratives = None # type: ignore
29
+ build_report_data = None # type: ignore
30
+
31
+ __all__ = [
32
+ "PUEReportData", "extract_master_sheet", "validate", "recompute",
33
+ "generate_narratives", "build_report_data",
34
+ ]
pue_report_agent/agent.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py
3
+ ========
4
+ The LangChain layer. Two responsibilities, kept deliberately narrow:
5
+
6
+ 1. **Narrative generation chains** — the only place an LLM is used. From the
7
+ review, the genuine value-add of AI is converting structured ESG / analysis
8
+ rows into the three prose passages a human would otherwise write by hand:
9
+ the Community Overview, the Demographic narrative, and the Processing
10
+ Insights. Everything else (tables, totals, boilerplate) is deterministic.
11
+
12
+ 2. **Orchestration** — a small graph that runs extraction, fans out the
13
+ narrative chains, validates, and hands a fully-populated `PUEReportData` to
14
+ the document generator.
15
+
16
+ Why constrain the LLM to prose only:
17
+ * Tables and budgets must reconcile to the spreadsheet to the naira. An LLM
18
+ re-typing numbers is how the Kwarua Tasha report ended up with figures that
19
+ didn't match its own master sheet. So numbers never pass through a prompt.
20
+ * Boilerplate is identical across reports; sending it to a model only adds
21
+ drift risk.
22
+
23
+ The chains use LangChain Expression Language (LCEL). Swap `ChatAnthropic` for
24
+ your provider of choice; the rest is provider-agnostic.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from typing import Optional
31
+
32
+ from langchain_core.prompts import ChatPromptTemplate
33
+ from langchain_core.output_parsers import StrOutputParser
34
+
35
+ from .schema import PUEReportData
36
+ from .extractors import extract_master_sheet
37
+
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # Models
41
+ # --------------------------------------------------------------------------- #
42
+ # All narrative prose is currently generated with Gemini. The model is
43
+ # swappable per call — any LangChain BaseChatModel works — so we can move
44
+ # individual chains to other providers later without touching the chains
45
+ # themselves. The import is local so the package still loads (for
46
+ # extraction/validation/render) when langchain-google-genai isn't installed.
47
+
48
+ def _gemini(temperature: float = 0.2):
49
+ from langchain_google_genai import ChatGoogleGenerativeAI
50
+ return ChatGoogleGenerativeAI(model="gemini-1.5-pro",
51
+ temperature=temperature, max_output_tokens=1200)
52
+
53
+
54
+ # Default model factory for every chain. Point this elsewhere to switch
55
+ # providers globally.
56
+ def _llm(temperature: float = 0.2):
57
+ return _gemini(temperature)
58
+
59
+
60
+ # --------------------------------------------------------------------------- #
61
+ # Shared system instruction — the guardrail against fabrication
62
+ # --------------------------------------------------------------------------- #
63
+ _SYSTEM = (
64
+ "You are a technical writer producing a Productive Use of Energy (PUE) "
65
+ "feasibility report for a rural Nigerian community. You write in formal, "
66
+ "neutral, third-person prose suitable for an investor-facing document. "
67
+ "Absolute rules:\n"
68
+ "1. Use ONLY the facts provided in the JSON. Never invent figures, names, "
69
+ "places, dates, or claims that are not present.\n"
70
+ "2. Do not output any number that is not in the JSON. If a number would "
71
+ "help but isn't supplied, write around it qualitatively.\n"
72
+ "3. No headings, bullet lists, or markdown — return clean paragraphs only.\n"
73
+ "4. If a field is empty, omit it gracefully rather than guessing."
74
+ )
75
+
76
+
77
+ # --------------------------------------------------------------------------- #
78
+ # Chain 1 — Community Overview
79
+ # --------------------------------------------------------------------------- #
80
+ _overview_prompt = ChatPromptTemplate.from_messages([
81
+ ("system", _SYSTEM),
82
+ ("human",
83
+ "Write a 2–3 paragraph Community Overview for the project site. Cover "
84
+ "location and access, dominant and minority ethnic groups and languages, "
85
+ "the main livelihoods, governance/community structures, the current "
86
+ "electricity situation, and the community's stated attitude to the "
87
+ "project. Here is the data:\n\n```json\n{community_json}\n```"),
88
+ ])
89
+
90
+
91
+ def _overview_chain(llm):
92
+ return _overview_prompt | llm | StrOutputParser()
93
+
94
+
95
+ # --------------------------------------------------------------------------- #
96
+ # Chain 2 — Demographic & Socio-Economic narrative
97
+ # --------------------------------------------------------------------------- #
98
+ _demographic_prompt = ChatPromptTemplate.from_messages([
99
+ ("system", _SYSTEM),
100
+ ("human",
101
+ "Write a 1–2 paragraph Demographic and Socio-Economic narrative that "
102
+ "introduces the table which will follow it. Summarise what most households "
103
+ "depend on, the dominant crops, the nature of agro-processing in the "
104
+ "community, and the SME and e-mobility activity levels. Use the interview "
105
+ "counts and crop/SME/mobility summaries provided. Data:\n\n"
106
+ "```json\n{summary_json}\n```"),
107
+ ])
108
+
109
+
110
+ def _demographic_chain(llm):
111
+ return _demographic_prompt | llm | StrOutputParser()
112
+
113
+
114
+ # --------------------------------------------------------------------------- #
115
+ # Chain 3 — Processing Insights
116
+ # --------------------------------------------------------------------------- #
117
+ _processing_prompt = ChatPromptTemplate.from_messages([
118
+ ("system", _SYSTEM),
119
+ ("human",
120
+ "Write a short Processing Insights passage (2 paragraphs max) describing "
121
+ "the agro-processing landscape: which crops are processed, the dominant "
122
+ "business model (cash-for-service vs goods-for-service), the gender "
123
+ "composition of processors, and the contrast between peak-season and "
124
+ "scarce-season throughput. Be qualitative about volumes; the exact figures "
125
+ "appear in the adjacent tables. Data:\n\n```json\n{processors_json}\n```"),
126
+ ])
127
+
128
+
129
+ def _processing_chain(llm):
130
+ return _processing_prompt | llm | StrOutputParser()
131
+
132
+
133
+ # --------------------------------------------------------------------------- #
134
+ # Orchestration
135
+ # --------------------------------------------------------------------------- #
136
+ def generate_narratives(data: PUEReportData,
137
+ llm=None) -> PUEReportData:
138
+ """
139
+ Run the three prose chains and attach results to the data object.
140
+
141
+ All narratives currently use Gemini (gemini-1.5-pro). Pass `llm` to override
142
+ with any LangChain chat model.
143
+ """
144
+ llm = llm or _gemini()
145
+
146
+ community_json = data.community.model_dump_json(indent=2)
147
+
148
+ summary_payload = {
149
+ "interviews": data.interviews.model_dump(),
150
+ "farmers": {"male": data.farmers.male, "female": data.farmers.female,
151
+ "crops": [c.model_dump() for c in data.farmers.crops]},
152
+ "sme": data.sme.model_dump(),
153
+ "mobility": data.mobility.model_dump(),
154
+ "key_agro_commodities": data.community.key_agro_commodities,
155
+ }
156
+ processors_json = data.processors.model_dump_json(indent=2)
157
+
158
+ data.narrative_overview = _overview_chain(llm).invoke(
159
+ {"community_json": community_json})
160
+ data.narrative_demographic = _demographic_chain(llm).invoke(
161
+ {"summary_json": json.dumps(summary_payload, indent=2)})
162
+ data.narrative_processing_insights = _processing_chain(llm).invoke(
163
+ {"processors_json": processors_json})
164
+ return data
165
+
166
+
167
+ def build_report_data(master_sheet_path: str,
168
+ developer: str = "the mini-grid developer",
169
+ run_narratives: bool = True) -> PUEReportData:
170
+ """
171
+ End-to-end: master sheet -> validated, narrated PUEReportData.
172
+
173
+ Deterministic extraction first, LLM prose second. All narratives are
174
+ produced by Gemini. The document renderer (render.py) consumes the result.
175
+ `developer` is an external input that isn't in the sheet.
176
+ """
177
+ from .calc import recompute
178
+ from .validate import validate
179
+ data = extract_master_sheet(master_sheet_path)
180
+ data = recompute(data)
181
+ data = validate(data)
182
+ if run_narratives:
183
+ data = generate_narratives(data)
184
+ return data
pue_report_agent/calc.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ calc.py
3
+ =======
4
+ Deterministic recomputation of every derived figure in the report.
5
+
6
+ The agent must NEVER reproduce a wrong total. The source template shipped with a
7
+ BOQ Grand Total of ₦30,756,000 when its own line items summed to ₦37,256,000
8
+ (the Farming line had been dropped). A master sheet can carry the same kind of
9
+ defect. So before rendering, the agent recomputes:
10
+
11
+ * each budget section total = Σ line-item total_price
12
+ * the BOQ grand total = Σ section totals
13
+ * each equipment TOTAL row = Σ power ratings (kW) and Σ quantities
14
+ * each energy-projection total= Σ projection rows
15
+ * each financial-model total = Σ (amount_monthly × quantity)
16
+
17
+ Recomputed values overwrite whatever was extracted, and any disagreement with
18
+ the sheet's own stated total is recorded in `warnings` so a human sees that the
19
+ source workbook didn't foot.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .schema import PUEReportData, EquipmentPlan
25
+
26
+
27
+ def recompute(data: PUEReportData) -> PUEReportData:
28
+ _budget(data)
29
+ _equipment(data.equipment_pilot, data)
30
+ _equipment(data.equipment_scaleup, data)
31
+ _energy_projection(data.equipment_pilot)
32
+ _energy_projection(data.equipment_scaleup)
33
+ _financials(data)
34
+ _co2(data)
35
+ return data
36
+
37
+
38
+ # IPCC default emission factors (kg CO2 per litre of fuel).
39
+ _CO2_FACTOR = {"Petrol": 2.31, "Diesel": 2.86}
40
+
41
+
42
+ def _co2(data: PUEReportData) -> None:
43
+ """Compute per-machine CO2 = annual fuel litres x IPCC factor, where litres
44
+ are available. Rows without a litres input keep co2_kg = None (flagged in
45
+ validation); they don't contribute to the total."""
46
+ for row in data.processors.co2_assessment:
47
+ if row.annual_fuel_litres is not None:
48
+ factor = _CO2_FACTOR.get(row.fuel_type)
49
+ if factor is not None:
50
+ row.co2_kg = round(row.annual_fuel_litres * factor, 2)
51
+
52
+
53
+ def _budget(data: PUEReportData) -> None:
54
+ grand = 0.0
55
+ for s in data.budget.sections:
56
+ line_sum = sum(i.total_price or 0 for i in s.items)
57
+ if s.items:
58
+ if s.total is not None and abs(line_sum - s.total) > 1:
59
+ data.warnings.append(
60
+ f"Budget section '{s.name}': sheet stated "
61
+ f"{s.total:,.0f} but line items sum to {line_sum:,.0f}. "
62
+ f"Using the recomputed figure.")
63
+ s.total = line_sum
64
+ grand += s.total or 0
65
+ if data.budget.grand_total is not None and abs(grand - data.budget.grand_total) > 1:
66
+ data.warnings.append(
67
+ f"BOQ grand total: sheet stated {data.budget.grand_total:,.0f} but "
68
+ f"sections sum to {grand:,.0f}. Using the recomputed figure.")
69
+ data.budget.grand_total = grand
70
+
71
+
72
+ def _equipment(plan: EquipmentPlan, data: PUEReportData) -> None:
73
+ """Add a recomputed TOTAL row to each equipment section.
74
+
75
+ The total power/energy is the sum of (per-unit rating x quantity), and it
76
+ lands in different columns depending on the table's layout:
77
+ * Power Rating + Quantity tables -> the QUANTITY total holds
78
+ sum(power_rating x quantity); the Power Rating total is left blank.
79
+ (e.g. cassava: 5.5x1 + 4x1 = 9.5 shown under Quantity)
80
+ * Charging Rate + Quantity tables -> the CHARGING RATE total holds
81
+ sum(charging_rate x quantity); Quantity totals as a plain count.
82
+ (e.g. 4 two-wheelers x 1.35 = 5.4 shown under Charging Rate)
83
+ Charging-time totals are left blank. The farming/irrigation section is
84
+ exempt and keeps simple sums (its template row is placeholder zeros).
85
+ """
86
+ for sec in plan.sections:
87
+ if not sec.rows:
88
+ sec.total_row = []
89
+ continue
90
+ ncols = len(sec.headers) or max(len(r) for r in sec.rows)
91
+ headers = [str(h).lower() for h in (sec.headers or [])]
92
+
93
+ def _is(kind, ci):
94
+ return ci < len(headers) and kind in headers[ci]
95
+
96
+ qty_col = next((ci for ci in range(ncols) if _is("quantity", ci)
97
+ or (ci < len(headers) and headers[ci].strip() == "qty")), None)
98
+ power_col = next((ci for ci in range(ncols)
99
+ if _is("power rating", ci) or _is("rating (kw)", ci)), None)
100
+ charge_col = next((ci for ci in range(ncols)
101
+ if _is("charging rate", ci)), None)
102
+ is_farming = ("farming" in (sec.name or "").lower() or
103
+ any("irrigation" in str(r[0]).lower() for r in sec.rows if r))
104
+
105
+ def _sum_product(rate_col):
106
+ s, ok = 0.0, False
107
+ for r in sec.rows:
108
+ val = _to_num(r[rate_col]) if rate_col < len(r) else None
109
+ q = _to_num(r[qty_col]) if qty_col < len(r) else None
110
+ if val is not None and q is not None:
111
+ s += val * q; ok = True
112
+ return (f"{s:g}" if ok else "")
113
+
114
+ total = ["" for _ in range(ncols)]
115
+ total[0] = "TOTAL"
116
+ for ci in range(1, ncols):
117
+ if _is("charging time", ci):
118
+ continue
119
+ if not is_farming and qty_col is not None:
120
+ # Quantity total = sum(power rating x qty) when a Power Rating
121
+ # column is present; otherwise a plain count.
122
+ if ci == qty_col and power_col is not None:
123
+ total[ci] = _sum_product(power_col)
124
+ continue
125
+ # Power Rating total stays blank (its product lives in Quantity).
126
+ if ci == power_col:
127
+ continue
128
+ # Charging Rate total = sum(charging rate x qty).
129
+ if ci == charge_col:
130
+ total[ci] = _sum_product(charge_col)
131
+ continue
132
+ # Plain column sum (plain Quantity count, or any other numeric col).
133
+ col_vals, numeric = [], True
134
+ for r in sec.rows:
135
+ v = r[ci] if ci < len(r) else None
136
+ if v in (None, ""):
137
+ continue
138
+ num = _to_num(v)
139
+ if num is None:
140
+ numeric = False
141
+ break
142
+ col_vals.append(num)
143
+ if numeric and col_vals:
144
+ total[ci] = f"{sum(col_vals):g}"
145
+ sec.total_row = total
146
+
147
+
148
+ def _to_num(v):
149
+ if isinstance(v, (int, float)):
150
+ return float(v)
151
+ try:
152
+ return float(str(v).replace(",", "").strip())
153
+ except (ValueError, AttributeError):
154
+ return None
155
+
156
+
157
+ def _energy_projection(plan: EquipmentPlan) -> None:
158
+ if not plan.projection:
159
+ return
160
+ total_kwh = sum(p.kwh for p in plan.projection)
161
+ total_six = sum(p.six_hour_day for p in plan.projection)
162
+ plan.projection_total_kwh = round(total_kwh, 2)
163
+ plan.projection_total_six_hour = round(total_six, 2)
164
+
165
+
166
+ def _financials(data: PUEReportData) -> None:
167
+ for m in data.financial_models:
168
+ m.total_monthly = sum(r.amount_monthly * r.quantity for r in m.rows) or None
pue_report_agent/extractors.py ADDED
@@ -0,0 +1,1037 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ extractors.py
3
+ =============
4
+ Reads a PUE master-sheet workbook and returns a validated `PUEReportData`.
5
+
6
+ Everything here is keyed to the *observed* layout of the DREEF master sheets
7
+ (Jaji + Kwarua Tasha). The layout is irregular — analysis sheets are
8
+ label/value blocks rather than tidy tables — so extraction is done with small
9
+ "block readers" that locate a section by its label and walk down/right from it
10
+ rather than by hardcoded cell addresses. That makes the agent resilient to rows
11
+ being inserted above a block.
12
+
13
+ Key resilience rules baked in (learned from the review):
14
+
15
+ 1. **Raw/analysis sheets win over Key Findings.** Key Findings carries stale
16
+ template text from other communities (e.g. "Ondo", "Oil palm and Cassava").
17
+ Where both exist, we read the analysis sheet and only fall back to Key
18
+ Findings for developer-supplied figures (capacities, revenue-per-user,
19
+ impact) that aren't derivable from raw data.
20
+
21
+ 2. **Template-pollution detection.** When a value matches a known stale
22
+ template string, we keep it but append a warning so a human reviews it.
23
+
24
+ 3. **Community-name fuzziness.** The sheets spell the community several ways
25
+ ("Jaji"/"Jaiji", "Kwarau"/"Kwarua Tasha"). We normalise on read.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from typing import Any, Optional
32
+
33
+ from openpyxl import load_workbook
34
+ from openpyxl.worksheet.worksheet import Worksheet
35
+
36
+ from .schema import (
37
+ PUEReportData, CommunityProfile, EnergySystemSpec, DistributionLine,
38
+ InterviewCounts, FarmerAnalysis, CropRow, ProcessorAnalysis, MachineRow,
39
+ ProcessingQtyRow, BusinessModelRow, MobilityAnalysis, SMEAnalysis,
40
+ SMETypeRow, EquipmentPlan, EquipmentSection, EnergyProjectionRow, Budget,
41
+ BudgetSection, BudgetLineItem, FinancialModel, FinancialRow,
42
+ RevenuePerUserRow, ImpactRow, DistanceRow, CO2EmissionRow,
43
+ )
44
+
45
+ # Strings that, if found verbatim, mean someone forgot to update the template.
46
+ STALE_TEMPLATE_MARKERS = [
47
+ "Oilpalm and Cassava", "Oil palm and Cassava", "Ondo",
48
+ "315.5 kWp", # appears in both Key Findings tabs regardless of community
49
+ "34 male vs 7 female",
50
+ ]
51
+
52
+
53
+ # --------------------------------------------------------------------------- #
54
+ # Low-level grid helpers
55
+ # --------------------------------------------------------------------------- #
56
+ def _rows(ws: Worksheet) -> list[list[Any]]:
57
+ """Whole sheet as a list-of-lists, values only. Cheap enough for these."""
58
+ return [list(r) for r in ws.iter_rows(values_only=True)]
59
+
60
+
61
+ def _norm(v: Any) -> str:
62
+ return ("" if v is None else str(v)).strip()
63
+
64
+
65
+ def _num(v: Any) -> Optional[float]:
66
+ if v is None:
67
+ return None
68
+ if isinstance(v, (int, float)):
69
+ return float(v)
70
+ s = str(v).replace(",", "").replace("₦", "").replace("#", "").strip()
71
+ s = re.sub(r"[^\d.\-]", "", s)
72
+ try:
73
+ return float(s) if s not in ("", "-", ".") else None
74
+ except ValueError:
75
+ return None
76
+
77
+
78
+ def _label_col(rows: list[list[Any]], label: str, start: int = 0,
79
+ exact: bool = False) -> tuple[int, int]:
80
+ """
81
+ Locate a label ANYWHERE in the grid. Returns (row_index, col_index) of the
82
+ matching cell, or (-1, -1). Master sheets often indent blocks into column B
83
+ (column A is a blank margin), so we cannot assume a fixed column.
84
+
85
+ `exact=True` requires the whole trimmed cell to equal the label, which
86
+ avoids 'Farmer' matching inside 'Farmers Analysis', etc.
87
+ """
88
+ needle = label.lower()
89
+ for i in range(start, len(rows)):
90
+ for c, cell in enumerate(rows[i]):
91
+ text = _norm(cell).lower()
92
+ if not text:
93
+ continue
94
+ if (text == needle) if exact else (needle in text):
95
+ return i, c
96
+ return -1, -1
97
+
98
+
99
+ def _find_row(rows: list[list[Any]], label: str, col: int = 0,
100
+ start: int = 0) -> int:
101
+ """Row index of the first cell containing `label` (searches all columns)."""
102
+ i, _ = _label_col(rows, label, start=start)
103
+ return i
104
+
105
+
106
+ def _value_to_right(rows: list[list[Any]], label: str,
107
+ label_col: int = 0, exact: bool = False) -> Optional[Any]:
108
+ """First non-empty cell to the right of wherever `label` is found."""
109
+ i, c = _label_col(rows, label, exact=exact)
110
+ if i < 0:
111
+ return None
112
+ for cc in range(c + 1, len(rows[i])):
113
+ if _norm(rows[i][cc]):
114
+ return rows[i][cc]
115
+ return None
116
+
117
+
118
+ def _cell(row: list[Any], idx: int) -> Any:
119
+ return row[idx] if 0 <= idx < len(row) else None
120
+
121
+
122
+ def _table_under(rows: list[list[Any]], header_label: str, n_cols: int,
123
+ start: int = 0) -> tuple[int, list[list[Any]]]:
124
+ """
125
+ Find a header by label; return (anchor_col, data_rows) with each row sliced
126
+ to `n_cols` columns starting at the header's column. Stops at the first row
127
+ whose anchor cell is blank. Handles the blank column-A margin used by these
128
+ sheets, where real content begins in column B.
129
+ """
130
+ i, c = _label_col(rows, header_label, start=start)
131
+ if i < 0:
132
+ return -1, []
133
+ out = []
134
+ for r in rows[i + 1:]:
135
+ if not _norm(_cell(r, c)):
136
+ break
137
+ out.append([_cell(r, c + k) for k in range(n_cols)])
138
+ return c, out
139
+
140
+
141
+ # --------------------------------------------------------------------------- #
142
+ # Section extractors
143
+ # --------------------------------------------------------------------------- #
144
+ def extract_community(wb, warnings: list[str]) -> CommunityProfile:
145
+ esg = _rows(wb["ESG"])
146
+ kf = _rows(wb["Key Findings"]) if "Key Findings" in wb.sheetnames else []
147
+
148
+ # ESG is a 2-row sheet: header row + single data row.
149
+ header = [_norm(c) for c in esg[0]]
150
+ data = esg[1] if len(esg) > 1 else []
151
+
152
+ def esg_get(col_contains: str) -> str:
153
+ for idx, h in enumerate(header):
154
+ if col_contains.lower() in h.lower():
155
+ return _norm(data[idx]) if idx < len(data) else ""
156
+ return ""
157
+
158
+ name = esg_get("Name of Community") or "Unknown"
159
+ name = re.sub(r"\bKwarau\b", "Kwarua", name).strip()
160
+
161
+ # Demographic block lives in Key Findings as label/detail pairs.
162
+ def kf_detail(label: str) -> str:
163
+ return _norm(_value_to_right(kf, label)) if kf else ""
164
+
165
+ # State sits below a "State" header in the KF top table (header/value layout).
166
+ state = ""
167
+ si, sc = _label_col(kf, "State", exact=True) if kf else (-1, -1)
168
+ if si >= 0 and si + 1 < len(kf):
169
+ state = _norm(_cell(kf[si + 1], sc))
170
+
171
+ profile = CommunityProfile(
172
+ name=name,
173
+ lga=esg_get("Local Government Area") or kf_detail("LGA"),
174
+ state=state,
175
+ latitude=_num(esg_get("latitude")) or 0.0,
176
+ longitude=_num(esg_get("longitude")) or 0.0,
177
+ topography_climate=kf_detail("Topography and Climate"),
178
+ land_use=kf_detail("Land Use"),
179
+ population_estimate=int(_num(kf_detail("Population Estimates")) or 0) or None,
180
+ households=int(_num(kf_detail("Number of Households")) or 0) or None,
181
+ major_ethnic_groups=kf_detail("Major Ethnic Groups")
182
+ or esg_get("Dominant Tribe"),
183
+ languages_spoken=kf_detail("Languages Spoken"),
184
+ key_economic_activities=kf_detail("Key Economic Activities")
185
+ or esg_get("primary economic activities"),
186
+ key_agro_commodities=kf_detail("Key Agro Commodities"),
187
+ processing_methods=kf_detail("Processing Methods"),
188
+ num_agro_processors=int(_num(kf_detail("Number of Agro-Processors")) or 0) or None,
189
+ farmers_cooperative=kf_detail("Farmers' Cooperative"),
190
+ infrastructure=kf_detail("Infrastructure"),
191
+ landmarks=esg_get("Major Landmarks"),
192
+ grid_status=esg_get("Nearest community with Grid"),
193
+ network_available=esg_get("Available Network"),
194
+ water_sources=esg_get("Sources of water"),
195
+ community_sentiment=esg_get("How does the community feel"),
196
+ land_ownership=esg_get("Who owns or occupies the land"),
197
+ community_structures=esg_get("existing community structures"),
198
+ )
199
+
200
+ # Security & Social Risks: compose a factual summary from the ESG risk
201
+ # questions (no invention — only what the respondent answered).
202
+ def _is_no(ans: str) -> bool:
203
+ return ans.strip().lower() in ("no", "none", "nil", "n")
204
+
205
+ def _is_yes(ans: str) -> bool:
206
+ return ans.strip().lower().startswith("yes")
207
+
208
+ sec = esg_get("victim of security issues")
209
+ land = esg_get("land disputes or boundary")
210
+ pol = esg_get("conflicting political actors")
211
+ clear, concerns = [], []
212
+ for label, ans in (("security incidents", sec),
213
+ ("land or boundary disputes", land),
214
+ ("political conflicts", pol)):
215
+ if not ans:
216
+ continue
217
+ (clear if _is_no(ans) else concerns).append(label)
218
+ parts = []
219
+ if clear:
220
+ parts.append("No reported " + ", ".join(clear) + ".")
221
+ if concerns:
222
+ parts.append("Reported concerns: " + ", ".join(concerns) + ".")
223
+ profile.security_social_risks = " ".join(parts)
224
+
225
+ # Flag stale template values pulled from Key Findings.
226
+ for field in ("key_agro_commodities", "state", "languages_spoken"):
227
+ val = getattr(profile, field)
228
+ if any(m.lower() in val.lower() for m in STALE_TEMPLATE_MARKERS):
229
+ warnings.append(
230
+ f"community.{field} = '{val}' matches a known template "
231
+ f"string and may be stale; verify against raw survey sheets."
232
+ )
233
+ return profile
234
+
235
+
236
+ def extract_interviews(wb) -> InterviewCounts:
237
+ kf = _rows(wb["Key Findings"])
238
+ def g(lbl): # noqa
239
+ return int(_num(_value_to_right(kf, lbl, exact=True)) or 0)
240
+ return InterviewCounts(
241
+ total=g("People interviewed"),
242
+ farmers=g("Farmer"),
243
+ agroprocessors=g("Agroprocessors"),
244
+ sme=g("SME"),
245
+ mobility=g("Mobility"),
246
+ )
247
+
248
+
249
+ def extract_farmers(wb) -> FarmerAnalysis:
250
+ fa = _rows(wb["Farmers Analysis Sheet "]) if \
251
+ "Farmers Analysis Sheet " in wb.sheetnames else _rows(wb["Farmers Analysis Sheet"])
252
+ male = int(_num(_value_to_right(fa, "Male")) or 0)
253
+ female = int(_num(_value_to_right(fa, "Female")) or 0)
254
+
255
+ # Crop table: header row is "Crop | Farming household | % | ... | Area".
256
+ # Anchor on "Crop" so crop names (leftmost col) are captured.
257
+ kf = _rows(wb["Key Findings"])
258
+ crops: list[CropRow] = []
259
+ fh = _find_row(kf, "Farming household")
260
+ _, table = _table_under(kf, "Crop", n_cols=6, start=max(0, fh - 1))
261
+ for row in table:
262
+ crop = _norm(row[0])
263
+ if not crop or _num(row[1]) is None:
264
+ break
265
+ crops.append(CropRow(
266
+ crop=crop,
267
+ farming_households=int(_num(row[1]) or 0),
268
+ percentage=round(_num(row[2]) or 0, 1),
269
+ annual_production_interviewed=_num(row[3]) or 0,
270
+ estimated_annual_production_community=_num(row[4]) or 0,
271
+ total_area_planted_hectares=_num(row[5]) or 0,
272
+ ))
273
+ return FarmerAnalysis(male=male, female=female, crops=crops)
274
+
275
+
276
+ def extract_processors(wb) -> ProcessorAnalysis:
277
+ pa = _rows(wb["Processors Analysis Sheet"])
278
+ male = int(_num(_value_to_right(pa, "Male")) or 0)
279
+ female = int(_num(_value_to_right(pa, "Female")) or 0)
280
+
281
+ # Processing quantity table: header "Crops Processed | Total Peak | Total
282
+ # Scarce | Avg Peak | Avg Scarce". Anchor on the metric header then step
283
+ # one column left to reach the crop names.
284
+ kf = _rows(wb["Key Findings"])
285
+ qty: list[ProcessingQtyRow] = []
286
+ qi, qc = _label_col(kf, "Total Quantity Processed (Peak)")
287
+ if qi >= 0:
288
+ crop_col = qc - 1
289
+ for r in kf[qi + 1:qi + 12]:
290
+ crop = _norm(_cell(r, crop_col))
291
+ if not crop or _num(_cell(r, qc)) is None:
292
+ break
293
+ qty.append(ProcessingQtyRow(
294
+ crop=crop,
295
+ total_peak=_num(_cell(r, qc)) or 0,
296
+ total_scarce=_num(_cell(r, qc + 1)) or 0,
297
+ avg_peak=_num(_cell(r, qc + 2)) or 0,
298
+ avg_scarce=_num(_cell(r, qc + 3)) or 0))
299
+
300
+ bms = _extract_business_models(kf)
301
+ # Machinery block sits AFTER the business-model table; scope the scan so the
302
+ # farmer crop table (which also contains "maize", "rice", ...) is excluded.
303
+ bm_row, _ = _label_col(kf, "cash as a service")
304
+ machines = _extract_machinery(kf, start=max(0, bm_row))
305
+ # Jaji-style grouped extraction is crop-group-keyed; communities like Abigi
306
+ # store machinery as a flat Machine/Specification/Quantity table with no
307
+ # group column, so fall back to a flat reader when nothing was found.
308
+ if not machines:
309
+ machines = _extract_machinery_flat(kf)
310
+
311
+ # Peak/scarce quantities: Jaji keeps them in Key Findings as a single table;
312
+ # Abigi keeps them in the Processors Analysis Sheet as four labelled blocks.
313
+ if not qty:
314
+ qty = _processing_qty_from_analysis(wb)
315
+
316
+ co2 = _co2_from_machinery(machines)
317
+
318
+ return ProcessorAnalysis(male=male, female=female,
319
+ business_models=bms, machinery=machines,
320
+ processing_quantities=qty, co2_assessment=co2)
321
+
322
+
323
+ def _processing_qty_from_analysis(wb) -> list[ProcessingQtyRow]:
324
+ """Fallback peak/scarce reader for sheets (e.g. Abigi) that store the four
325
+ metrics in the Processors Analysis Sheet as labelled blocks:
326
+
327
+ Total Quantity Processed (Peak) | Cassava | 960
328
+ | Oil Palm | 96
329
+ Average Quantity (Peak) | Cassava | 240
330
+ ...
331
+ """
332
+ if "Processors Analysis Sheet" not in wb.sheetnames:
333
+ return []
334
+ pa = _rows(wb["Processors Analysis Sheet"])
335
+ metrics = [("Total Quantity Processed (Peak)", "total_peak"),
336
+ ("Average Quantity (Peak)", "avg_peak"),
337
+ ("Total Quantity Processed (Scarce)", "total_scarce"),
338
+ ("Average Quantity Processed (Scarce)", "avg_scarce")]
339
+ data: dict = {}
340
+ order: list = []
341
+ for label, field in metrics:
342
+ i, c = _label_col(pa, label)
343
+ if i < 0:
344
+ continue
345
+ for k in range(i, min(i + 12, len(pa))):
346
+ crop = _norm(_cell(pa[k], c + 1))
347
+ val = _num(_cell(pa[k], c + 2))
348
+ # A new metric label in column c ends this block.
349
+ other = _norm(_cell(pa[k], c))
350
+ if k > i and other and _num(other) is None:
351
+ break
352
+ if not crop or val is None:
353
+ if k > i:
354
+ break
355
+ continue
356
+ key = crop.strip().lower()
357
+ if key not in data:
358
+ data[key] = {"crop": crop.strip()}
359
+ order.append(key)
360
+ data[key][field] = val
361
+ return [ProcessingQtyRow(
362
+ crop=data[k]["crop"],
363
+ total_peak=data[k].get("total_peak", 0),
364
+ total_scarce=data[k].get("total_scarce", 0),
365
+ avg_peak=data[k].get("avg_peak", 0),
366
+ avg_scarce=data[k].get("avg_scarce", 0)) for k in order]
367
+
368
+
369
+ def _co2_from_machinery(machines) -> list[CO2EmissionRow]:
370
+ """Build CO2-assessment rows for the fuel-powered machines (Petrol/Diesel)
371
+ found in the machinery list. Per-machine annual fuel consumption is not in
372
+ the master sheets, so annual_fuel_litres/co2_kg are left None here and the
373
+ figures are computed in calc.recompute() once litres are supplied; validate
374
+ flags the missing input.
375
+ """
376
+ out: list[CO2EmissionRow] = []
377
+ for m in machines:
378
+ if m.power_source in ("Petrol", "Diesel"):
379
+ out.append(CO2EmissionRow(
380
+ machine=m.machine,
381
+ specification=m.rated_power,
382
+ fuel_type=m.power_source,
383
+ num_machines=m.count))
384
+ return out
385
+
386
+
387
+ def _extract_business_models(kf) -> list[BusinessModelRow]:
388
+ # Header row is "Crops Processed | Male | Female | cash as a service | ...".
389
+ out: list[BusinessModelRow] = []
390
+ _, table = _table_under(kf, "Crops Processed", n_cols=6)
391
+ for row in table:
392
+ crop = _norm(row[0])
393
+ if not crop or _num(row[1]) is None:
394
+ break
395
+ out.append(BusinessModelRow(
396
+ crop=crop, male=int(_num(row[1]) or 0), female=int(_num(row[2]) or 0),
397
+ cash_as_service=int(_num(row[3]) or 0),
398
+ goods_as_service=int(_num(row[4]) or 0),
399
+ purchase_raw_materials=int(_num(row[5]) or 0)))
400
+ return out
401
+
402
+
403
+ _FUEL_SOURCES = (("diesel", "Diesel"), ("petrol", "Petrol"),
404
+ ("electric", "Electric"), ("solar", "Solar"),
405
+ ("manual", "Manual"))
406
+ _FUEL_EMISSION_KG_PER_L = {"Petrol": 2.31, "Diesel": 2.86} # IPCC factors
407
+
408
+
409
+ def _parse_machine_spec(spec: str) -> tuple:
410
+ """Split a free-text machine specification into (power_source, rated_power).
411
+
412
+ Sheets vary: Jaji writes "15 HP electric motor machine" (rating + source in
413
+ one string); Abigi's flat table puts EITHER a rating ("13hp", "6.5 hp") OR a
414
+ source ("Petrol", "Manual") in the cell. We pull whatever is present; either
415
+ field may come back empty.
416
+ """
417
+ s = (spec or "").strip()
418
+ low = s.lower()
419
+ source = ""
420
+ for kw, label in _FUEL_SOURCES:
421
+ if kw in low:
422
+ source = label
423
+ break
424
+ if source:
425
+ # Rating + source share one string (e.g. "15 HP electric motor"):
426
+ # pull just the rating out.
427
+ m = re.search(r"\d+(?:\s*\.\s*\d+)?\s*(?:hp|horse\s*power)", low)
428
+ rated = s[m.start():m.end()].strip() if m else ""
429
+ else:
430
+ # No fuel keyword: the whole cell is the rating (e.g. "6 .5 hp",
431
+ # "13hp", "20 horse power") — keep it intact rather than risk a partial
432
+ # regex match on malformed text.
433
+ rated = s if any(ch.isdigit() for ch in s) else ""
434
+ return source, rated
435
+
436
+
437
+ def _mk_machine(crop_group: str, machine: str, spec: str,
438
+ count: int) -> MachineRow:
439
+ source, rated = _parse_machine_spec(spec)
440
+ return MachineRow(crop_group=crop_group, machine=machine,
441
+ specification=spec, count=count,
442
+ power_source=source, rated_power=rated)
443
+
444
+
445
+ def _extract_machinery(kf, start: int = 0) -> list[MachineRow]:
446
+ """
447
+ Machinery rows sit under crop-group headers where the group name shares the
448
+ row of the first machine, e.g.:
449
+ | maize | Chaf remover | 15 horse power | 3 |
450
+ | | Chaf remover | 20 horse power | 1 |
451
+ Group is in column B; machine/spec/count in C/D/E. `start` scopes the scan
452
+ past the farmer crop table, which also contains crop-group words.
453
+ """
454
+ out: list[MachineRow] = []
455
+ groups = {"maize", "rice", "sawmill", "saw mill", "multiple grains"}
456
+ current, in_block = "", False
457
+ _, anchor = _label_col(kf, "Crops Processed")
458
+ if anchor < 0:
459
+ anchor = 1
460
+ for r in kf[start:]:
461
+ g = _norm(_cell(r, anchor)).lower()
462
+ machine = _norm(_cell(r, anchor + 1))
463
+ spec = _norm(_cell(r, anchor + 2))
464
+ cnt = _num(_cell(r, anchor + 3))
465
+ # A genuine machine name is non-numeric. Numeric "machine" cells are
466
+ # bleed-through from the business-model and quantity tables.
467
+ machine_is_real = bool(machine) and _num(machine) is None
468
+ if g in groups and machine_is_real:
469
+ current, in_block = _norm(_cell(r, anchor)), True
470
+ if cnt is not None:
471
+ out.append(_mk_machine(current, machine, spec, int(cnt)))
472
+ elif in_block and not g and machine_is_real:
473
+ if cnt is not None:
474
+ out.append(_mk_machine(current, machine, spec, int(cnt)))
475
+ elif in_block and g and g not in groups:
476
+ in_block = False
477
+ return out
478
+
479
+
480
+ def _extract_machinery_flat(kf) -> list[MachineRow]:
481
+ """Fallback for sheets whose machinery is a flat table with no crop-group
482
+ column, e.g. Abigi's Key Findings:
483
+
484
+ | Machine | Specification | Quantity |
485
+ | Digester | 6.5 hp | 1 |
486
+ | Grater | 13hp | 2 |
487
+
488
+ Located by a header row containing 'Machine' and 'Quantity'. Rows are read
489
+ until a blank/zero-padding row. crop_group is left generic because this
490
+ layout does not tie a machine to a specific crop.
491
+ """
492
+ out: list[MachineRow] = []
493
+ header_i = header_c = -1
494
+ for i, row in enumerate(kf):
495
+ cells = [_norm(x).lower() for x in row]
496
+ if any(x == "machine" for x in cells) and \
497
+ any("quantity" in x or x == "qty" for x in cells):
498
+ header_i = i
499
+ header_c = next(j for j, x in enumerate(cells) if x == "machine")
500
+ break
501
+ if header_i < 0:
502
+ return out
503
+ for r in kf[header_i + 1:header_i + 30]:
504
+ machine = _norm(_cell(r, header_c))
505
+ spec = _norm(_cell(r, header_c + 1))
506
+ cnt = _num(_cell(r, header_c + 2))
507
+ # Stop at blank or numeric-named padding rows (Abigi pads with "0|0|0").
508
+ if not machine or _num(machine) is not None:
509
+ break
510
+ if cnt is None or int(cnt) == 0:
511
+ continue
512
+ out.append(_mk_machine("processing equipment", machine, spec, int(cnt)))
513
+ return out
514
+
515
+
516
+ def extract_mobility(wb) -> MobilityAnalysis:
517
+ name = ("E-Mobility Analysis Sheet " if "E-Mobility Analysis Sheet " in
518
+ wb.sheetnames else "E-Mobility Analysis Sheet")
519
+ ma = _rows(wb[name])
520
+
521
+ def block_total(label):
522
+ """A '... Revenue/Expenses/Profit' block lists Total then Average."""
523
+ i, c = _label_col(ma, label)
524
+ if i < 0:
525
+ return None
526
+ for r in ma[i:i + 3]:
527
+ if "total" in [_norm(x).lower() for x in r]:
528
+ nums = [_num(x) for x in r if _num(x) is not None]
529
+ return nums[0] if nums else None
530
+ return None
531
+
532
+ def block_avg(label):
533
+ """The 'Average' row sits just under the 'Total' row in each block."""
534
+ i, c = _label_col(ma, label)
535
+ if i < 0:
536
+ return None
537
+ for r in ma[i:i + 3]:
538
+ if "average" in [_norm(x).lower() for x in r]:
539
+ nums = [_num(x) for x in r if _num(x) is not None]
540
+ return nums[0] if nums else None
541
+ return None
542
+
543
+ kf = _rows(wb["Key Findings"])
544
+ return MobilityAnalysis(
545
+ total=int(_num(_value_to_right(kf, "E-Mobility", exact=True)) or 0),
546
+ okada=int(_num(_value_to_right(kf, "Okada", exact=True)) or 0),
547
+ tricycle=int(_num(_value_to_right(kf, "Tricycle", exact=True)) or 0),
548
+ boat=int(_num(_value_to_right(kf, "Boat", exact=True)) or 0),
549
+ daily_revenue_total=block_total("Daily Revenue"),
550
+ daily_revenue_avg=block_avg("Daily Revenue"),
551
+ daily_fuel_expense_total=block_total("Daily Expenses (Fuel)"),
552
+ daily_fuel_expense_avg=block_avg("Daily Expenses (Fuel)"),
553
+ daily_profit_total=block_total("Daily Profit"),
554
+ daily_profit_avg=block_avg("Daily Profit"),
555
+ )
556
+
557
+
558
+ def extract_sme(wb) -> SMEAnalysis:
559
+ kf = _rows(wb["Key Findings"])
560
+ # Only used to LOCATE the block (the SME cell whose next row is a business
561
+ # type). We accept both Jaji's misspellings and the correct spellings so the
562
+ # locator works across sheets; row capture below does NOT depend on this set,
563
+ # so a community with new/relabelled SME types still extracts fully.
564
+ known = {"barbershop", "cold storage/ice block making", "grocery retail",
565
+ "phone charging", "phone repair", "cain production",
566
+ "cane production", "hairdressing", "welding", "printing/cybercafé",
567
+ "carpentary", "carpentry", "tailoring/fashion", "others"}
568
+ types: list[SMETypeRow] = []
569
+ total = 0
570
+ start = 0
571
+ while True:
572
+ i, c = _label_col(kf, "SME", start=start, exact=True)
573
+ if i < 0:
574
+ break
575
+ nxt = _norm(_cell(kf[i + 1], c)) if i + 1 < len(kf) else ""
576
+ if nxt.lower() in known:
577
+ total = int(_num(_cell(kf[i], c + 1)) or 0)
578
+ # Read every contiguous (label, count) row until the block ends.
579
+ # A row belongs to the block when its label cell is non-empty,
580
+ # non-numeric text and the cell to its right is a number. This is
581
+ # spelling-agnostic, so correctly-spelled "Cane Production" /
582
+ # "Carpentry" (Abigi) no longer terminate the scan early.
583
+ for r in kf[i + 1:i + 20]:
584
+ label = _norm(_cell(r, c))
585
+ cnt = _num(_cell(r, c + 1))
586
+ if label and _num(label) is None and cnt is not None:
587
+ types.append(SMETypeRow(
588
+ business_type=label, count=int(cnt)))
589
+ elif types:
590
+ break
591
+ break
592
+ start = i + 1
593
+ return SMEAnalysis(total=total, types=types)
594
+
595
+
596
+ _EQUIP_HEADER_TOKENS = ("capacity", "power", "quantity", "energy source",
597
+ "charging rate", "charging time", "machine", "vehicles",
598
+ "rating", "unit")
599
+
600
+
601
+ def _is_equip_header(row) -> bool:
602
+ cells = [_norm(c).lower() for c in row if _norm(c)]
603
+ hits = sum(1 for c in cells
604
+ if any(tok in c for tok in _EQUIP_HEADER_TOKENS))
605
+ return hits >= 2
606
+
607
+
608
+ def extract_equipment(wb, sheet_name: str, phase: str) -> EquipmentPlan:
609
+ rows = _rows(wb[sheet_name])
610
+ plan = EquipmentPlan(phase=phase)
611
+
612
+ pending_label = "" # nearest single-cell label above a header row
613
+ i = 0
614
+ n = len(rows)
615
+ while i < n:
616
+ r = rows[i]
617
+ nonempty = [(ci, _norm(c)) for ci, c in enumerate(r) if _norm(c)]
618
+
619
+ # Stop scanning sections once we reach the energy-projection block.
620
+ if any("energy projection" in t.lower() for _, t in nonempty):
621
+ break
622
+
623
+ if not nonempty:
624
+ i += 1
625
+ continue
626
+
627
+ if _is_equip_header(r):
628
+ anchor = nonempty[0][0]
629
+ last_col = nonempty[-1][0]
630
+ width = last_col - anchor + 1
631
+ headers = [_norm(_cell(r, anchor + k)) for k in range(width)]
632
+ section = EquipmentSection(
633
+ name=pending_label or "Equipment", headers=headers)
634
+ j = i + 1
635
+ while j < n:
636
+ rr = rows[j]
637
+ first = _norm(_cell(rr, anchor))
638
+ low = first.lower()
639
+ if not first:
640
+ break
641
+ if _is_equip_header(rr):
642
+ break
643
+ if "energy projection" in " ".join(
644
+ _norm(c).lower() for c in rr):
645
+ break
646
+ if low in ("total", "total cost"):
647
+ j += 1
648
+ break # recomputed by calc; don't store sheet total
649
+ section.rows.append(
650
+ [_cell(rr, anchor + k) for k in range(width)])
651
+ j += 1
652
+ if section.rows:
653
+ plan.sections.append(section)
654
+ pending_label = ""
655
+ i = j
656
+ continue
657
+
658
+ # A single-content row is a candidate section label.
659
+ if len(nonempty) == 1 and _num(nonempty[0][1]) is None:
660
+ pending_label = nonempty[0][1]
661
+ i += 1
662
+
663
+ # Energy projection block (kW/hr and 6-hour-day columns).
664
+ pi, pc = _label_col(rows, "TOTAL ENERGY PROJECTION")
665
+ if pi >= 0:
666
+ for rr in rows[pi + 2:pi + 14]:
667
+ item = _norm(_cell(rr, pc))
668
+ if not item:
669
+ break
670
+ kwh = _num(_cell(rr, pc + 1))
671
+ six = _num(_cell(rr, pc + 2))
672
+ if item.upper() == "TOTAL":
673
+ plan.projection_total_kwh = kwh
674
+ plan.projection_total_six_hour = six
675
+ break
676
+ if kwh is None:
677
+ continue
678
+ plan.projection.append(EnergyProjectionRow(
679
+ item=item, kwh=kwh or 0, six_hour_day=six or 0))
680
+ return plan
681
+
682
+
683
+ def extract_energy_systems(wb, warnings) -> tuple:
684
+ """Pull mini-grid and micro stand-alone capacities + distribution metrics
685
+ from Key Findings. These are developer-supplied figures that nonetheless
686
+ live in the sheet; extracting them avoids '[to be supplied]' gaps."""
687
+ kf = _rows(wb["Key Findings"])
688
+
689
+ def parse_block(start_label: str, stop_labels: list[str]) -> EnergySystemSpec:
690
+ i, _ = _label_col(kf, start_label)
691
+ spec = EnergySystemSpec()
692
+ if i < 0:
693
+ return spec
694
+ # scan a window after the label for the capacity lines + distribution
695
+ end = len(kf)
696
+ for k in range(i + 1, len(kf)):
697
+ line = " ".join(_norm(c) for c in kf[k])
698
+ if any(s.lower() in line.lower() for s in stop_labels):
699
+ end = k
700
+ break
701
+ in_dist = False
702
+ for k in range(i, end):
703
+ line = " ".join(_norm(c) for c in kf[k] if _norm(c)).strip()
704
+ low = line.lower()
705
+ if not line:
706
+ continue
707
+ if "solar pv" in low:
708
+ spec.solar_pv = line.split(":", 1)[-1].strip()
709
+ elif "inverter" in low:
710
+ spec.inverter = line.split(":", 1)[-1].strip()
711
+ elif "battery storage" in low:
712
+ spec.battery_storage = line.split(":", 1)[-1].strip()
713
+ elif "annual consumption" in low:
714
+ spec.annual_consumption = line.split(":", 1)[-1].strip()
715
+ elif "distribution metrics" in low or "target end users" in low:
716
+ in_dist = True
717
+ elif in_dist and "(" in line and ")" in line and \
718
+ any(ch.isdigit() for ch in line[line.find("("):line.find(")")]):
719
+ # e.g. "Residential (90.34%): Households..." or "Public (1.75):"
720
+ name = line.split("(", 1)[0]
721
+ name = re.sub(r"^[\s·•\-\u00a0]+", "", name).strip()
722
+ pct = line[line.find("(") + 1:line.find(")")].strip()
723
+ if pct and not pct.endswith("%"):
724
+ pct = pct + "%"
725
+ desc = line.split(":", 1)[-1].strip() if ":" in line else ""
726
+ if name:
727
+ spec.distribution_metrics.append(DistributionLine(
728
+ end_user=name, percentage=pct, description=desc))
729
+ spec.developer_supplied = True
730
+ if any(m.lower() in (spec.solar_pv + spec.battery_storage).lower()
731
+ for m in STALE_TEMPLATE_MARKERS):
732
+ spec.needs_review = True
733
+ return spec
734
+
735
+ # NB: the sheet's headings are the anchors, not the report's. This workbook
736
+ # labels the blocks "Mini-Grid Development Project" and "Micro Stand-Alone
737
+ # Energy System..." (hyphenated). Earlier versions looked for "Proposed
738
+ # Capacity"/"Micro Stand Alone" which don't appear in the sheet, so both
739
+ # blocks came back empty and rendered as "[to be supplied]".
740
+ minigrid = parse_block("Mini-Grid Development Project",
741
+ ["Micro Stand-Alone", "Micro Stand Alone",
742
+ "Energy Substitution", "Distance from"])
743
+ micro = parse_block("Micro Stand-Alone Energy System",
744
+ ["Energy Substitution", "Distance from",
745
+ "Name of SME", "Name of Processor"])
746
+ # Flag the known stale micro-distribution text without altering it.
747
+ for d in micro.distribution_metrics:
748
+ if any(m.lower() in d.description.lower() for m in STALE_TEMPLATE_MARKERS):
749
+ warnings.append(
750
+ f"Micro distribution metric '{d.end_user}' description "
751
+ f"('{d.description[:40]}...') matches stale template text; "
752
+ f"verify it reflects this community.")
753
+ return minigrid, micro
754
+
755
+
756
+ def extract_budget(wb) -> Budget:
757
+ rows = _rows(wb["Budget"])
758
+ budget = Budget()
759
+ section_names = {"energy infrastructure", "energy", "energy box",
760
+ "agricultural hub", "agrohub", "sunbread", "farming",
761
+ "swap station", "mobility", "sme", "others"}
762
+
763
+ def map_columns(header_row: list[Any]) -> Optional[dict]:
764
+ """Map item/details/qty/unit/total to column indices from a header row.
765
+
766
+ Sections vary: the standard block is Item|Details|Quantity|Unit Price|
767
+ Total Price, but e.g. OTHERS is S/N|cat|Item|Qty|Unit Cost|Total. We
768
+ locate each field by its header text so offsets don't matter."""
769
+ cols = {}
770
+ for ci, cell in enumerate(header_row):
771
+ t = _norm(cell).lower()
772
+ if not t:
773
+ continue
774
+ if t in ("item", "machine") and "item" not in cols:
775
+ cols["item"] = ci
776
+ elif t in ("details", "specification", "spec", "capacity"):
777
+ cols.setdefault("details", ci)
778
+ elif t.startswith("qty") or t.startswith("quantity"):
779
+ cols["qty"] = ci
780
+ elif "unit" in t:
781
+ cols["unit"] = ci
782
+ elif t.startswith("total"):
783
+ cols["total"] = ci
784
+ # Some sections name the first column differently (e.g. "E-Mobility
785
+ # Vehicles"). If no explicit item header was found, use the first
786
+ # non-empty header cell as the item column.
787
+ if "item" not in cols:
788
+ for ci, cell in enumerate(header_row):
789
+ if _norm(cell):
790
+ cols["item"] = ci
791
+ break
792
+ return cols if "item" in cols and "total" in cols else None
793
+
794
+ i = 0
795
+ while i < len(rows):
796
+ # find a section header
797
+ label = next((_norm(c) for c in rows[i] if _norm(c)), "")
798
+ if label.lower() in section_names:
799
+ section = BudgetSection(name=label)
800
+ budget.sections.append(section)
801
+ # the header row is the next non-empty row
802
+ j = i + 1
803
+ cols = None
804
+ while j < len(rows):
805
+ if any(_norm(c) for c in rows[j]):
806
+ cols = map_columns(rows[j])
807
+ j += 1
808
+ break
809
+ j += 1
810
+ if not cols:
811
+ i = j
812
+ continue
813
+ # read line items until a TOTAL row or blank/new-section row
814
+ while j < len(rows):
815
+ r = rows[j]
816
+ item = _norm(_cell(r, cols["item"]))
817
+ low = item.lower()
818
+ row_labels = [_norm(c).lower() for c in r]
819
+ if not item:
820
+ break
821
+ if low in section_names:
822
+ break
823
+ # TOTAL may sit in any column (e.g. OTHERS puts it in the
824
+ # category column, not the item column).
825
+ if low in ("total", "total cost") or \
826
+ "total" in row_labels or "total cost" in row_labels:
827
+ section.total = _num(_cell(r, cols["total"]))
828
+ j += 1
829
+ break
830
+ # If a non-empty, non-numeric category sits immediately left of
831
+ # the item column (as in the OTHERS block), prepend it so rows
832
+ # aren't all identically named ("Transportation & Logistics").
833
+ label = item
834
+ if cols["item"] > 0:
835
+ left = _norm(_cell(r, cols["item"] - 1))
836
+ if left and left != "0" and _num(left) is None \
837
+ and left.lower() not in ("item", "s/n", "machine"):
838
+ label = f"{left} \u2014 {item}"
839
+ section.items.append(BudgetLineItem(
840
+ section=section.name, item=label,
841
+ details=_norm(_cell(r, cols.get("details", -1)))
842
+ if "details" in cols else "",
843
+ quantity=_num(_cell(r, cols.get("qty", -1)))
844
+ if "qty" in cols else None,
845
+ unit_price=_num(_cell(r, cols.get("unit", -1)))
846
+ if "unit" in cols else None,
847
+ total_price=_num(_cell(r, cols["total"]))))
848
+ j += 1
849
+ i = j
850
+ else:
851
+ i += 1
852
+
853
+ budget.grand_total = sum(s.total for s in budget.sections if s.total) or None
854
+ return budget
855
+
856
+
857
+ def extract_financials(wb) -> list[FinancialModel]:
858
+ rows = _rows(wb["Summary"])
859
+ models: list[FinancialModel] = []
860
+ current: Optional[FinancialModel] = None
861
+ model_names = {"individual model - new equipment",
862
+ "individual model - retrofit", "agrohub model"}
863
+ # model name and beneficiary live in adjacent columns; find the model column
864
+ _, mcol = _label_col(rows, "Business Model")
865
+ if mcol < 0:
866
+ mcol = 1
867
+ bcol = mcol + 1
868
+ for r in rows:
869
+ name_cell = _norm(_cell(r, mcol)).strip()
870
+ if name_cell.lower() in model_names:
871
+ current = FinancialModel(model_name=name_cell)
872
+ models.append(current)
873
+ _append_fin_row(current, r, _norm(_cell(r, bcol)), bcol)
874
+ continue
875
+ if current is None:
876
+ continue
877
+ _append_fin_row(current, r, _norm(_cell(r, bcol)), bcol)
878
+ for m in models:
879
+ m.total_monthly = sum(x.amount_monthly * x.quantity for x in m.rows) or None
880
+ return models
881
+
882
+
883
+ def _append_fin_row(model: FinancialModel, r, beneficiary, bcol):
884
+ if not beneficiary:
885
+ return
886
+ qty = _num(_cell(r, bcol + 1))
887
+ tf = _num(_cell(r, bcol + 2))
888
+ amt = _num(_cell(r, bcol + 3))
889
+ if amt is None: # skip #DIV/0! rows (e.g. Farming with qty 0)
890
+ return
891
+ model.rows.append(FinancialRow(
892
+ beneficiary=beneficiary, quantity=qty or 0,
893
+ timeframe_months=tf or 0, amount_monthly=amt))
894
+
895
+
896
+ def extract_revenue_per_user(wb, warnings) -> list[RevenuePerUserRow]:
897
+ kf = _rows(wb["Key Findings"])
898
+ i, c = _label_col(kf, "Effective Daily Demand")
899
+ out: list[RevenuePerUserRow] = []
900
+ if i < 0:
901
+ return out
902
+ cat_col = c - 1 if c > 0 else 0
903
+ for r in kf[i + 1:i + 8]:
904
+ cat = _norm(_cell(r, cat_col))
905
+ if not cat:
906
+ break
907
+ out.append(RevenuePerUserRow(
908
+ category=cat,
909
+ effective_daily_demand_kwh=_num(_cell(r, cat_col + 1)) or 0,
910
+ effective_annual_demand_kwh=_num(_cell(r, cat_col + 2)) or 0,
911
+ beneficiaries=int(_num(_cell(r, cat_col + 3)) or 0),
912
+ avg_tariff=_num(_cell(r, cat_col + 4)) or 0,
913
+ daily_revenue_category=_num(_cell(r, cat_col + 5)) or 0,
914
+ daily_avg_revenue=_num(_cell(r, cat_col + 6)) or 0))
915
+ if out:
916
+ warnings.append(
917
+ "revenue_per_user is developer-supplied and is frequently shared "
918
+ "across multiple communities. Confirm beneficiary counts and the "
919
+ "header label match THIS site before publishing.")
920
+ return out
921
+
922
+
923
+ def extract_impact(wb) -> list[ImpactRow]:
924
+ kf = _rows(wb["Key Findings"])
925
+ _, table = _table_under(kf, "Impact Area", n_cols=3)
926
+ out: list[ImpactRow] = []
927
+ for row in table:
928
+ area = _norm(row[0])
929
+ if not area:
930
+ break
931
+ out.append(ImpactRow(impact_area=area, metric=_norm(row[1]),
932
+ projected_outcome=_norm(row[2])))
933
+ return out
934
+
935
+
936
+ def extract_distances(wb, community=None) -> list[DistanceRow]:
937
+ kf = _rows(wb["Key Findings"])
938
+ _, table = _table_under(kf, "Name of Processor", n_cols=4)
939
+ out: list[DistanceRow] = []
940
+ for row in table:
941
+ name = _norm(row[0])
942
+ if not name:
943
+ break
944
+ parts = _norm(row[1]).split()
945
+ out.append(DistanceRow(
946
+ name=name,
947
+ site_lat=_num(parts[0]) if parts else None,
948
+ site_long=_num(parts[1]) if len(parts) > 1 else None,
949
+ minigrid_gps=_norm(row[2]),
950
+ distance_km=_num(row[3])))
951
+ # Fallback (e.g. Abigi): no precomputed distance table in Key Findings.
952
+ # The Processors sheet carries a Name column and a GPS cell whose first two
953
+ # numbers are the site coordinates; the mini-grid reference point is the
954
+ # community centre, so distances are computed by haversine.
955
+ if not out and community is not None:
956
+ out = _distances_from_processors(wb, community)
957
+ return out
958
+
959
+
960
+ def _haversine_km(lat1, lon1, lat2, lon2) -> float:
961
+ import math
962
+ r = 6371.0088
963
+ p1, p2 = math.radians(lat1), math.radians(lat2)
964
+ dp = math.radians(lat2 - lat1)
965
+ dl = math.radians(lon2 - lon1)
966
+ a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
967
+ return 2 * r * math.asin(math.sqrt(a))
968
+
969
+
970
+ def _distances_from_processors(wb, community) -> list[DistanceRow]:
971
+ if "Processors" not in wb.sheetnames:
972
+ return []
973
+ rows = _rows(wb["Processors"])
974
+ # Locate the Name and GPS columns from the header row.
975
+ name_c = gps_c = -1
976
+ for hdr in rows[:3]:
977
+ for j, cell in enumerate(hdr):
978
+ t = _norm(cell).lower()
979
+ if t == "name":
980
+ name_c = j
981
+ elif t == "gps":
982
+ gps_c = j
983
+ if name_c >= 0 and gps_c >= 0:
984
+ break
985
+ if name_c < 0 or gps_c < 0:
986
+ return []
987
+ # Community centre = mini-grid reference. KF stores it as (x=6.49, y=4.39);
988
+ # the GPS cells use the same x/y order, so we stay internally consistent.
989
+ mg_x, mg_y = community.latitude, community.longitude
990
+ if not (mg_x and mg_y):
991
+ return []
992
+ out: list[DistanceRow] = []
993
+ for r in rows[1:]:
994
+ name = _norm(_cell(r, name_c))
995
+ gps = _norm(_cell(r, gps_c))
996
+ if not name or name.lower() == "name" or not gps:
997
+ continue
998
+ nums = [_num(x) for x in gps.replace(",", " ").split() if _num(x) is not None]
999
+ if len(nums) < 2:
1000
+ continue
1001
+ sx, sy = nums[0], nums[1]
1002
+ dist = _haversine_km(sy, sx, mg_y, mg_x)
1003
+ out.append(DistanceRow(
1004
+ name=name, site_lat=sy, site_long=sx,
1005
+ minigrid_gps=f"{mg_x}, {mg_y}", distance_km=round(dist, 3)))
1006
+ return out
1007
+
1008
+
1009
+ # --------------------------------------------------------------------------- #
1010
+ # Public entry point
1011
+ # --------------------------------------------------------------------------- #
1012
+ def extract_master_sheet(path: str) -> PUEReportData:
1013
+ wb = load_workbook(path, read_only=True, data_only=True)
1014
+ warnings: list[str] = []
1015
+
1016
+ minigrid, micro = extract_energy_systems(wb, warnings)
1017
+ community = extract_community(wb, warnings)
1018
+ data = PUEReportData(
1019
+ community=community,
1020
+ minigrid=minigrid,
1021
+ microgrid=micro,
1022
+ interviews=extract_interviews(wb),
1023
+ farmers=extract_farmers(wb),
1024
+ processors=extract_processors(wb),
1025
+ mobility=extract_mobility(wb),
1026
+ sme=extract_sme(wb),
1027
+ equipment_pilot=extract_equipment(wb, "Proposed Pilot PUE Equipment", "Pilot"),
1028
+ equipment_scaleup=extract_equipment(wb, "Proposed ScaleUp PUE Equipment ", "ScaleUp"),
1029
+ budget=extract_budget(wb),
1030
+ financial_models=extract_financials(wb),
1031
+ revenue_per_user=extract_revenue_per_user(wb, warnings),
1032
+ projected_impact=extract_impact(wb),
1033
+ processor_distances=extract_distances(wb, community),
1034
+ warnings=warnings,
1035
+ )
1036
+ wb.close()
1037
+ return data
pue_report_agent/fallback_narrative.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fallback_narrative.py
3
+ ======================
4
+ Deterministic prose composed straight from the extracted data, used to fill the
5
+ three narrative slots when the LLM (Gemini) hasn't been run — so the report is
6
+ always complete and readable, never showing a "[narrative pending]" placeholder.
7
+
8
+ These are plain factual sentences built from fields that are already in the
9
+ schema; no numbers are invented. When `generate_narratives()` runs, its output
10
+ overrides these.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .schema import PUEReportData
16
+
17
+
18
+ def _join(items: list[str]) -> str:
19
+ items = [i for i in items if i]
20
+ if not items:
21
+ return ""
22
+ if len(items) == 1:
23
+ return items[0]
24
+ return ", ".join(items[:-1]) + " and " + items[-1]
25
+
26
+
27
+ def community_overview(data: PUEReportData) -> str:
28
+ c = data.community
29
+ bits = []
30
+ loc = f"{c.name} is a community in {c.lga}" + (f", {c.state} State" if c.state else "") + "."
31
+ bits.append(loc)
32
+ if c.latitude and c.longitude:
33
+ bits.append(f"It is located at approximately {c.latitude}, {c.longitude}.")
34
+ if c.topography_climate:
35
+ bits.append(f"The area lies within a {c.topography_climate.rstrip('.').lower()}.")
36
+ para1 = " ".join(bits)
37
+
38
+ bits2 = []
39
+ if c.major_ethnic_groups:
40
+ bits2.append(f"The population is made up of {c.major_ethnic_groups.rstrip('.').lower()}.")
41
+ if c.languages_spoken:
42
+ bits2.append(f"Languages spoken include {c.languages_spoken.rstrip('.').lower()}.")
43
+ if c.key_economic_activities:
44
+ bits2.append(f"The main economic activities are {c.key_economic_activities.rstrip('.').lower()}.")
45
+ if c.population_estimate:
46
+ hh = f" across roughly {c.households:,} households" if c.households else ""
47
+ bits2.append(f"The community has an estimated population of {c.population_estimate:,}{hh}.")
48
+ para2 = " ".join(bits2)
49
+
50
+ bits3 = []
51
+ if c.infrastructure:
52
+ bits3.append(f"In terms of infrastructure, the community has {c.infrastructure.rstrip('.').lower()}.")
53
+ if c.farmers_cooperative:
54
+ bits3.append(f"Cooperative structure: {c.farmers_cooperative.rstrip('.').lower()}.")
55
+ para3 = " ".join(bits3)
56
+
57
+ return "\n\n".join(p for p in (para1, para2, para3) if p)
58
+
59
+
60
+ def demographic_narrative(data: PUEReportData) -> str:
61
+ iv = data.interviews
62
+ f = data.farmers
63
+ commodities = data.community.key_agro_commodities
64
+ crops = _join([cr.crop for cr in f.crops[:6]])
65
+ sme_types = _join([t.business_type for t in data.sme.types[:5]])
66
+
67
+ s = []
68
+ s.append(
69
+ f"A total of {iv.total:,} respondents were interviewed across the "
70
+ f"community, comprising {iv.farmers:,} farmers, {iv.agroprocessors:,} "
71
+ f"agro-processors, {iv.sme:,} SME operators and {iv.mobility:,} "
72
+ f"mobility operators.")
73
+ if f.male or f.female:
74
+ s.append(f"Among the farmers interviewed, {f.male:,} were male and "
75
+ f"{f.female:,} female.")
76
+ if commodities:
77
+ s.append(f"The community's key agro-commodities are {commodities.rstrip('.')}.")
78
+ elif crops:
79
+ s.append(f"The most commonly cultivated crops include {crops}.")
80
+ if data.mobility.total:
81
+ s.append(f"Mobility activity is dominated by {data.mobility.okada:,} "
82
+ f"okada and {data.mobility.tricycle:,} tricycle operators.")
83
+ if sme_types:
84
+ s.append(f"SME activity spans {sme_types}, among others.")
85
+ return " ".join(s)
86
+
87
+
88
+ def processing_insights(data: PUEReportData) -> str:
89
+ p = data.processors
90
+ crops = _join([b.crop for b in p.business_models])
91
+ s = []
92
+ if p.male or p.female:
93
+ s.append(f"The community's agro-processing is carried out by "
94
+ f"{p.male:,} male and {p.female:,} female processors.")
95
+ if crops:
96
+ s.append(f"Processing covers {crops}.")
97
+ # business model lean
98
+ cash = sum(b.cash_as_service for b in p.business_models)
99
+ goods = sum(b.goods_as_service for b in p.business_models)
100
+ if cash or goods:
101
+ lean = ("predominantly on a cash-for-service basis" if cash >= goods
102
+ else "predominantly on a goods-for-service basis")
103
+ s.append(f"Processors operate {lean}.")
104
+ if p.processing_quantities:
105
+ peak = sum(q.total_peak for q in p.processing_quantities)
106
+ scarce = sum(q.total_scarce for q in p.processing_quantities)
107
+ if peak and scarce:
108
+ s.append(f"Throughput is markedly seasonal: combined processing "
109
+ f"falls from about {peak:,.0f} units in the peak season to "
110
+ f"roughly {scarce:,.0f} in the scarce season, underlining "
111
+ f"the case for reliable, lower-cost electric processing.")
112
+ return " ".join(s)
113
+
114
+
115
+ def fill_missing(data: PUEReportData) -> PUEReportData:
116
+ """Populate any empty narrative slot with a deterministic fallback."""
117
+ if not data.narrative_overview.strip():
118
+ data.narrative_overview = community_overview(data)
119
+ if not data.narrative_demographic.strip():
120
+ data.narrative_demographic = demographic_narrative(data)
121
+ if not data.narrative_processing_insights.strip():
122
+ data.narrative_processing_insights = processing_insights(data)
123
+ return data
pue_report_agent/render.py ADDED
@@ -0,0 +1,911 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ render.py
3
+ =========
4
+ Deterministic .docx writer that reproduces the DREEF PUE report TEMPLATE's
5
+ structure, tone, headings, captions, table of contents and list of tables for
6
+ ANY community's data.
7
+
8
+ What makes the output match the template:
9
+
10
+ * **Title page** in the template's exact layout.
11
+ * **Auto Table of Contents** — a real Word TOC field (\\o "1-3") that populates
12
+ and renumbers itself; set to update on open.
13
+ * **Auto List of Tables** — a Word Table-of-Figures field keyed to the "Table"
14
+ caption sequence, so it always lists every table with the right page.
15
+ * **SEQ-field caption numbering** — every caption is "Table " + a `SEQ Table`
16
+ field (or "Figure " + `SEQ Figure`). Word computes the numbers, so they are
17
+ always sequential and correct no matter how many tables a community needs.
18
+ Nothing is hard-numbered.
19
+ * **Recomputed calculations** — totals come from calc.recompute(), never from a
20
+ possibly-wrong sheet total.
21
+ * **Verbatim boilerplate** — pulled from templates.py so tone/wording match.
22
+
23
+ The LLM's prose (narrative_* fields) drops into the three narrative slots; every
24
+ number and table is deterministic.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from docx import Document
30
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
31
+ from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
32
+ from docx.oxml import OxmlElement
33
+ from docx.oxml.ns import qn
34
+ from docx.shared import Pt, RGBColor, Inches
35
+
36
+ from .schema import PUEReportData
37
+ from . import templates as T
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Word field / caption helpers
42
+ # --------------------------------------------------------------------------- #
43
+ def _field(paragraph, instr: str, placeholder: str = "", size=None) -> None:
44
+ """Append a complex Word field (begin / instrText / separate / end)."""
45
+ run = paragraph.add_run()
46
+ if size is not None:
47
+ run.font.size = size
48
+ b = OxmlElement("w:fldChar"); b.set(qn("w:fldCharType"), "begin")
49
+ it = OxmlElement("w:instrText"); it.set(qn("xml:space"), "preserve")
50
+ it.text = instr
51
+ sep = OxmlElement("w:fldChar"); sep.set(qn("w:fldCharType"), "separate")
52
+ txt = OxmlElement("w:t"); txt.text = placeholder
53
+ end = OxmlElement("w:fldChar"); end.set(qn("w:fldCharType"), "end")
54
+ r = run._r
55
+ for el in (b, it, sep, txt, end):
56
+ r.append(el)
57
+
58
+
59
+ def _toc(doc) -> None:
60
+ p = doc.add_paragraph()
61
+ _field(p, 'TOC \\o "1-3" \\h \\z \\u', "Right-click and Update Field to "
62
+ "build the Table of Contents.")
63
+
64
+
65
+ def _list_of_tables(doc) -> None:
66
+ p = doc.add_paragraph()
67
+ # Table-of-Figures collecting the "Table" caption sequence.
68
+ _field(p, 'TOC \\h \\z \\c "Table"', "Right-click and Update Field to build "
69
+ "the List of Tables.")
70
+
71
+
72
+ _ACRONYMS = {
73
+ "SME", "SMEs", "CO2", "CO\u2082", "GPS", "PUE", "IOT", "IoT", "BOQ", "SDG",
74
+ "SDGs", "SPV", "KPI", "MER", "LGA", "DREEF", "AI", "ESG", "PV", "NEW",
75
+ "kW", "kWh", "kWp", "MW", "MWh", "MWp", "hp", "II", "III", "IV",
76
+ }
77
+ _MINOR_WORDS = {"a", "an", "and", "as", "at", "but", "by", "for", "from", "in",
78
+ "of", "on", "or", "per", "the", "to", "vs", "with", "&"}
79
+
80
+
81
+ def _title_case(text: str) -> str:
82
+ """Title-case a caption, preserving known acronyms/units and keeping minor
83
+ words lowercase (except the first word)."""
84
+ words = text.split(" ")
85
+ out = []
86
+ for i, w in enumerate(words):
87
+ if not w:
88
+ out.append(w)
89
+ continue
90
+ # Preserve tokens that are exactly a known acronym/unit.
91
+ if w in _ACRONYMS or w.upper() in {a.upper() for a in _ACRONYMS}:
92
+ match = next((a for a in _ACRONYMS if a.upper() == w.upper()), w)
93
+ out.append(match)
94
+ continue
95
+ # Keep tokens that contain digits as-is (e.g. "1ton/hr", "250l").
96
+ if any(ch.isdigit() for ch in w):
97
+ out.append(w)
98
+ continue
99
+ low = w.lower()
100
+ if i != 0 and low in _MINOR_WORDS:
101
+ out.append(low)
102
+ else:
103
+ out.append(w[0].upper() + w[1:])
104
+ return " ".join(out)
105
+
106
+
107
+ def _caption(doc, text: str, seq: str = "Table") -> None:
108
+ """Auto-numbered caption: '<seq> N: text'.
109
+
110
+ Uses a real SEQ field (so Word's List of Tables can collect it AND it
111
+ renumbers if tables move), but caches the CORRECT current number as the
112
+ field's displayed value — so it reads 'Table 1', 'Table 2', ... immediately
113
+ on open, never the placeholder 'Table 0'."""
114
+ counters = getattr(doc, "_seq_counters", None)
115
+ if counters is None:
116
+ counters = {}
117
+ doc._seq_counters = counters
118
+ counters[seq] = counters.get(seq, 0) + 1
119
+ current = str(counters[seq])
120
+
121
+ style = "Caption" if "Caption" in [s.name for s in doc.styles] else None
122
+ p = doc.add_paragraph(style=style)
123
+ r1 = p.add_run(f"{seq} ")
124
+ r1.font.size = Pt(9); r1.font.bold = False; r1.font.italic = True
125
+ _field(p, f"SEQ {seq} \\* ARABIC", current, size=Pt(9))
126
+ r2 = p.add_run(f": {_title_case(text)}")
127
+ r2.font.size = Pt(9); r2.font.bold = False; r2.font.italic = True
128
+
129
+
130
+ def _set_update_fields_on_open(doc) -> None:
131
+ """Make Word refresh TOC / List of Tables / SEQ numbers when first opened.
132
+
133
+ `w:updateFields` must appear in its schema-mandated position in
134
+ CT_Settings (before w:compat / w:rsids / w:mathPr / etc.), so we insert it
135
+ ahead of the first such trailing element rather than appending.
136
+ """
137
+ settings = doc.settings.element
138
+
139
+ # python-docx's default <w:zoom/> lacks the required percent attribute.
140
+ zoom = settings.find(qn("w:zoom"))
141
+ if zoom is not None and zoom.get(qn("w:percent")) is None:
142
+ zoom.set(qn("w:percent"), "100")
143
+
144
+ if settings.find(qn("w:updateFields")) is not None:
145
+ return
146
+ uf = OxmlElement("w:updateFields"); uf.set(qn("w:val"), "true")
147
+ # Elements that, per the schema, must come AFTER updateFields.
148
+ trailing = [qn(f"w:{t}") for t in (
149
+ "footnotePr", "endnotePr", "compat", "rsids", "mathPr",
150
+ "themeFontLang", "clrSchemeMapping", "shapeDefaults",
151
+ "decimalSymbol", "listSeparator")]
152
+ anchor = None
153
+ for child in settings:
154
+ if child.tag in trailing:
155
+ anchor = child
156
+ break
157
+ if anchor is not None:
158
+ anchor.addprevious(uf)
159
+ else:
160
+ settings.append(uf)
161
+
162
+
163
+ # --------------------------------------------------------------------------- #
164
+ # Table builder
165
+ # --------------------------------------------------------------------------- #
166
+ _HEADER_FILL = "1F4E79"
167
+ _HEADER_TEXT = RGBColor(0xFF, 0xFF, 0xFF)
168
+
169
+
170
+ def _shade(cell, fill: str) -> None:
171
+ tcPr = cell._tc.get_or_add_tcPr()
172
+ sh = OxmlElement("w:shd")
173
+ sh.set(qn("w:val"), "clear"); sh.set(qn("w:color"), "auto")
174
+ sh.set(qn("w:fill"), fill)
175
+ tcPr.append(sh)
176
+
177
+
178
+ def _table(doc, headers, rows, widths=None, header_fill=_HEADER_FILL):
179
+ cols = len(headers)
180
+ tbl = doc.add_table(rows=1, cols=cols)
181
+ tbl.style = "Table Grid"
182
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
183
+ hdr = tbl.rows[0].cells
184
+ for i, h in enumerate(headers):
185
+ hdr[i].text = ""
186
+ run = hdr[i].paragraphs[0].add_run(str(h))
187
+ run.bold = True
188
+ run.font.color.rgb = _HEADER_TEXT
189
+ run.font.size = Pt(11)
190
+ _shade(hdr[i], header_fill)
191
+ for row in rows:
192
+ cells = tbl.add_row().cells
193
+ first = str(row[0]).strip().upper() if row and row[0] is not None else ""
194
+ is_total = first.startswith("TOTAL") or first in (
195
+ "GRAND TOTAL", "TOTAL COST", "TOTAL CExpense")
196
+ for i in range(cols):
197
+ val = row[i] if i < len(row) else ""
198
+ cells[i].text = ""
199
+ run = cells[i].paragraphs[0].add_run("" if val is None else str(val))
200
+ run.font.size = Pt(11)
201
+ if is_total:
202
+ run.bold = True
203
+ if widths:
204
+ for row in tbl.rows:
205
+ for i, w in enumerate(widths):
206
+ if i < len(row.cells):
207
+ row.cells[i].width = w
208
+ return tbl
209
+
210
+
211
+ def _machinery_table(doc, group: str, rows):
212
+ """Crop-group machinery table where the first column is a single cell
213
+ (the crop name, e.g. 'maize') vertically merged across every row and
214
+ centred, alongside a Machine | Specification / Power | Qty mini-table.
215
+ Works for any crop group (rice, sawmill, cassava, oil palm, ...)."""
216
+ n = 1 + len(rows) # header row + one per machine
217
+ tbl = doc.add_table(rows=n, cols=4)
218
+ tbl.style = "Table Grid"
219
+ tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
220
+
221
+ def _put(cell, text, *, bold=False, white=False, size=11, center=False):
222
+ cell.text = ""
223
+ p = cell.paragraphs[0]
224
+ if center:
225
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
226
+ run = p.add_run("" if text is None else str(text))
227
+ run.bold = bold
228
+ run.font.size = Pt(size)
229
+ if white:
230
+ run.font.color.rgb = _HEADER_TEXT
231
+
232
+ # Column-header row for the three right-hand columns.
233
+ for ci, htext in enumerate(("Machine", "Specification / Power", "Qty"), start=1):
234
+ _put(tbl.cell(0, ci), htext, bold=True, white=True)
235
+ _shade(tbl.cell(0, ci), _HEADER_FILL)
236
+ # Machine rows.
237
+ for ri, m in enumerate(rows, start=1):
238
+ _put(tbl.cell(ri, 1), m.machine)
239
+ _put(tbl.cell(ri, 2), m.specification)
240
+ _put(tbl.cell(ri, 3), m.count)
241
+
242
+ # Merge the entire first column into one centred crop-name cell.
243
+ merged = tbl.cell(0, 0).merge(tbl.cell(n - 1, 0))
244
+ merged.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
245
+ _put(merged, group, bold=True, white=True, center=True)
246
+ _shade(merged, _HEADER_FILL)
247
+ return tbl
248
+
249
+
250
+ def _money(v) -> str:
251
+ return "" if v in (None, "") else f"{float(v):,.0f}"
252
+
253
+
254
+ # Section headings the reference renders in full caps (matches item 9).
255
+ _CAPS_HEADINGS = {
256
+ "introduction", "about the contractor", "project site and location",
257
+ "methodology used", "key findings",
258
+ }
259
+
260
+
261
+ def _heading(doc, text, level):
262
+ if text.strip().lower() in _CAPS_HEADINGS:
263
+ text = text.upper()
264
+ return doc.add_heading(text, level=level)
265
+
266
+
267
+ # --------------------------------------------------------------------------- #
268
+ # Main entry
269
+ # --------------------------------------------------------------------------- #
270
+ def render(data: PUEReportData, out_path: str,
271
+ developer: str = "the mini-grid developer",
272
+ report_date: str = "") -> str:
273
+ doc = Document()
274
+ doc._seq_counters = {}
275
+ _base_styles(doc)
276
+ from .fallback_narrative import fill_missing
277
+ fill_missing(data)
278
+ c = data.community
279
+
280
+ # ---- Title page -------------------------------------------------------
281
+ for _ in range(6): # push the title toward mid-page
282
+ doc.add_paragraph()
283
+ tp = doc.add_paragraph()
284
+ tp.alignment = WD_ALIGN_PARAGRAPH.CENTER
285
+ r = tp.add_run(T.TITLE_BLOCK["title"].format(community_upper=c.name.upper()))
286
+ r.bold = True; r.font.size = Pt(28)
287
+ doc.add_paragraph()
288
+ for i, line in enumerate(T.TITLE_BLOCK["subtitle_lines"]):
289
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
290
+ rr = p.add_run(line)
291
+ rr.font.size = Pt(14)
292
+ rr.bold = (i == len(T.TITLE_BLOCK["subtitle_lines"]) - 1) # bold DREEF
293
+ if report_date:
294
+ for _ in range(4):
295
+ doc.add_paragraph()
296
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
297
+ rr = p.add_run(report_date); rr.font.size = Pt(13); rr.bold = True
298
+ doc.add_page_break()
299
+
300
+ # ---- Table of Contents -----------------------------------------------
301
+ _heading(doc, "Table of Contents", 1)
302
+ _toc(doc)
303
+ doc.add_page_break()
304
+
305
+ # ---- List of Tables ---------------------------------------------------
306
+ _heading(doc, "List of Tables", 1)
307
+ _list_of_tables(doc)
308
+ doc.add_page_break()
309
+
310
+ _introduction(doc, c, developer)
311
+ _project_site(doc, data, developer)
312
+ _methodology(doc)
313
+ _key_findings(doc, data)
314
+ _proposed_equipment(doc, data)
315
+ _infrastructure(doc, data)
316
+ _boq_budget(doc, data)
317
+ _financial_model(doc, data)
318
+ _revenue_per_user(doc, data)
319
+ _implementation_plan(doc, c)
320
+ _conditions_for_scaleup(doc)
321
+ _mer(doc, c)
322
+ _risks(doc, c)
323
+ _impact(doc, data)
324
+ _sdgs(doc)
325
+ _challenges(doc)
326
+ _ai_disclosure(doc)
327
+
328
+ _set_update_fields_on_open(doc)
329
+ doc.save(out_path)
330
+ return out_path
331
+
332
+
333
+ # --------------------------------------------------------------------------- #
334
+ # Section renderers (template order)
335
+ # --------------------------------------------------------------------------- #
336
+ def _base_styles(doc) -> None:
337
+ normal = doc.styles["Normal"]
338
+ normal.font.name = "Aptos"
339
+ normal.font.size = Pt(11)
340
+ # Apply Aptos to the other built-in styles we use (Word's defaults would
341
+ # otherwise keep Calibri Light on headings / Calibri on captions).
342
+ for sname in ("Title", "Heading 1", "Heading 2", "Heading 3",
343
+ "Heading 4", "Caption"):
344
+ if sname in [s.name for s in doc.styles]:
345
+ doc.styles[sname].font.name = "Aptos"
346
+ if "Caption" in [s.name for s in doc.styles]:
347
+ cap = doc.styles["Caption"]
348
+ cap.font.size = Pt(9)
349
+ cap.font.bold = False
350
+ cap.font.italic = True
351
+
352
+
353
+ def _add_hyperlink(paragraph, url, text, size=Pt(11)):
354
+ """Append a real external hyperlink run (blue, underlined) to a paragraph."""
355
+ part = paragraph.part
356
+ r_id = part.relate_to(
357
+ url, "http://schemas.openxmlformats.org/officeDocument/2006/"
358
+ "relationships/hyperlink", is_external=True)
359
+ link = OxmlElement("w:hyperlink")
360
+ link.set(qn("r:id"), r_id)
361
+ run = OxmlElement("w:r")
362
+ rPr = OxmlElement("w:rPr")
363
+ color = OxmlElement("w:color"); color.set(qn("w:val"), "0563C1")
364
+ u = OxmlElement("w:u"); u.set(qn("w:val"), "single")
365
+ rPr.append(color); rPr.append(u)
366
+ if size is not None:
367
+ sz = OxmlElement("w:sz"); sz.set(qn("w:val"), str(int(size.pt * 2)))
368
+ rPr.append(sz)
369
+ run.append(rPr)
370
+ t = OxmlElement("w:t"); t.set(qn("xml:space"), "preserve"); t.text = text
371
+ run.append(t)
372
+ link.append(run)
373
+ paragraph._p.append(link)
374
+ return link
375
+
376
+
377
+ def _introduction(doc, c, developer):
378
+ _heading(doc, "Introduction", 1)
379
+ intro = T.INTRODUCTION
380
+ doc.add_paragraph(intro["lead"].format(community=c.name, developer=developer))
381
+ doc.add_paragraph(intro["objectives_lead"])
382
+ for obj in intro["objectives"]:
383
+ doc.add_paragraph(obj, style="List Bullet")
384
+ doc.add_paragraph(intro["closing"])
385
+
386
+ _heading(doc, "About the Contractor", 1)
387
+ ac = T.ABOUT_CONTRACTOR
388
+ doc.add_paragraph(ac["body"])
389
+ p = doc.add_paragraph()
390
+ r = p.add_run(ac["link_lead"]); r.font.size = Pt(11)
391
+ _add_hyperlink(p, ac["link_url"], ac["link_text"])
392
+ tail = p.add_run(ac["link_tail"]); tail.font.size = Pt(11)
393
+
394
+
395
+ def _project_site(doc, data, developer):
396
+ c = data.community
397
+ _heading(doc, "Project Site and Location", 1)
398
+ _heading(doc, f"{c.name} Community Overview", 2)
399
+ for para in (data.narrative_overview or "").split("\n\n"):
400
+ if para.strip():
401
+ doc.add_paragraph(para.strip())
402
+
403
+ _heading(doc, "Geographic Profile", 2)
404
+ _table(doc, ["Community Name", "LGA", "Coordinates (Lat, Long)", "State"],
405
+ [[c.name, c.lga, f"{c.latitude}, {c.longitude}", c.state]])
406
+ _caption(doc, "GPS of community")
407
+
408
+ _heading(doc, "Demographic and Socio Economic Profile", 2)
409
+ for para in (data.narrative_demographic or "").split("\n\n"):
410
+ if para.strip():
411
+ doc.add_paragraph(para.strip())
412
+ _table(doc, ["Category", "Details"], [
413
+ ["Topography and Climate", c.topography_climate],
414
+ ["Land Use", c.land_use],
415
+ ["Population Estimates", c.population_estimate],
416
+ ["Number of Households", c.households],
417
+ ["Major Ethnic Groups", c.major_ethnic_groups],
418
+ ["Languages Spoken", c.languages_spoken],
419
+ ["Key Economic Activities", c.key_economic_activities],
420
+ ["Key Agro Commodities", c.key_agro_commodities],
421
+ ["Processing Methods", c.processing_methods],
422
+ ["Number of Agro-Processors", c.num_agro_processors],
423
+ ["Farmers' Cooperative", c.farmers_cooperative],
424
+ ["Infrastructure", c.infrastructure],
425
+ ["Security & Social Risks", c.security_social_risks],
426
+ ])
427
+ _caption(doc, "Demographic and Socio Economic Profile of community")
428
+
429
+ # Mini-grid + micro capacities (developer-supplied)
430
+ _heading(doc, "Mini-Grid Development Project", 2)
431
+ _capacity_block(doc, data.minigrid)
432
+ _heading(doc, "Micro Stand Alone Energy System Development Project", 2)
433
+ _capacity_block(doc, data.microgrid)
434
+
435
+ _heading(doc, "Energy Substitution for Different Activities", 2)
436
+ doc.add_paragraph("The mini grid will replace or supplement existing energy "
437
+ "sources as outlined below:")
438
+ # Only crops with actual processing activity belong here — otherwise the
439
+ # list fills with template crop rows that have all-zero data (Abigi carries
440
+ # cocoa, sesame, kolanut, millet... at zero alongside cassava/oil palm).
441
+ def _bm_active(b):
442
+ return (b.male or b.female or b.cash_as_service or b.goods_as_service
443
+ or b.purchase_raw_materials)
444
+ crops = [b.crop for b in data.processors.business_models if _bm_active(b)] \
445
+ or [b.crop for b in data.processors.business_models] \
446
+ or [m.crop_group for m in data.processors.machinery]
447
+ seen, crop_list = set(), []
448
+ for cr in crops:
449
+ key = cr.strip().lower()
450
+ if key and key not in seen:
451
+ seen.add(key); crop_list.append(cr.strip())
452
+ proc_label = ("Processing (" + ", ".join(crop_list) + ")") if crop_list \
453
+ else "Agro-processing"
454
+ energy_sub = [
455
+ ("Household lighting", "Kerosene lamps, petrol generators",
456
+ "Solar electricity"),
457
+ (proc_label, "Diesel/petrol engines", "Electric PUE machines"),
458
+ ("SME", "Small petrol generators", "Reliable solar mini-grid power"),
459
+ ("Mobility", "Petrol powered two-wheelers",
460
+ "Electric powered two-wheelers"),
461
+ ]
462
+ _table(doc, ["Use Case", "Current Energy Source", "Replacement"],
463
+ [list(r) for r in energy_sub])
464
+ _caption(doc, "Energy Substitution for Different Activities")
465
+
466
+ _heading(doc, "Distance from Processing Sites to Proposed Minigrid", 2)
467
+ if data.processor_distances:
468
+ _table(doc, ["Name of Processor", "Site GPS", "Minigrid GPS",
469
+ "Distance (km)"],
470
+ [[d.name, f"{d.site_lat}, {d.site_long}" if d.site_lat else "",
471
+ d.minigrid_gps, d.distance_km]
472
+ for d in data.processor_distances])
473
+ else:
474
+ _table(doc, ["Name of Processor", "Site GPS", "Minigrid GPS",
475
+ "Distance (km)"], [["", "", "", ""]])
476
+ _caption(doc, "Distance from Processing Sites to Proposed Minigrid")
477
+
478
+
479
+ def _capacity_block(doc, spec):
480
+ _heading(doc, "Proposed Capacity", 3)
481
+ doc.add_paragraph(f"Solar PV: {spec.solar_pv or '[to be supplied]'}",
482
+ style="List Bullet")
483
+ if spec.inverter:
484
+ doc.add_paragraph(f"PV Inverter: {spec.inverter}", style="List Bullet")
485
+ doc.add_paragraph(f"Battery Storage: {spec.battery_storage or '[to be supplied]'}",
486
+ style="List Bullet")
487
+ doc.add_paragraph(f"Annual Consumption: {spec.annual_consumption or '[to be supplied]'}",
488
+ style="List Bullet")
489
+ _heading(doc, "Distribution Metrics", 3)
490
+ if spec.distribution_metrics:
491
+ for d in spec.distribution_metrics:
492
+ doc.add_paragraph(f"{d.end_user} ({d.percentage}): {d.description}",
493
+ style="List Bullet")
494
+ else:
495
+ doc.add_paragraph("[Distribution metrics to be supplied by the "
496
+ "mini-grid developer.]", style="List Bullet")
497
+
498
+
499
+ def _methodology(doc):
500
+ _heading(doc, "Methodology Used", 1)
501
+ _heading(doc, T.METHODOLOGY_HEADING, 2)
502
+ doc.add_paragraph(T.METHODOLOGY)
503
+
504
+
505
+ def _key_findings(doc, data):
506
+ c = data.community
507
+ _heading(doc, "Key Findings", 1)
508
+ _heading(doc, f"Income Generating Activities of {c.name}", 2)
509
+ iv = data.interviews
510
+ _table(doc, ["People interviewed", str(iv.total)],
511
+ [["Farmer", iv.farmers], ["Agroprocessors", iv.agroprocessors],
512
+ ["SME", iv.sme], ["Mobility", iv.mobility]])
513
+ _caption(doc, "Income generating activities")
514
+
515
+ # Farmers
516
+ _heading(doc, "Farmers", 2)
517
+ _table(doc, ["Gender", "Number of Farmers"],
518
+ [["Male", data.farmers.male], ["Female", data.farmers.female]])
519
+ _caption(doc, "Gender of interviewed Farmers")
520
+ if data.farmers.crops:
521
+ _crops = [cr for cr in data.farmers.crops
522
+ if cr.farming_households or cr.annual_production_interviewed
523
+ or cr.estimated_annual_production_community]
524
+ if _crops:
525
+ _table(doc, ["Crop", "Farming household", "Percentage",
526
+ "Annual Production Interviewed",
527
+ "Estimated Annual Production (100kg bag) Community",
528
+ "Total Area Planted (m²)"],
529
+ [[cr.crop, cr.farming_households, f"{cr.percentage:.1f}",
530
+ f"{cr.annual_production_interviewed:,.0f}",
531
+ f"{cr.estimated_annual_production_community:,.2f}",
532
+ f"{cr.total_area_planted_hectares:,.0f}"]
533
+ for cr in _crops])
534
+ _caption(doc, "Total Annual Quantity Harvested")
535
+
536
+ # Agro-processing
537
+ _heading(doc, "Agro Processing", 2)
538
+ if (data.narrative_processing_insights or "").strip():
539
+ doc.add_paragraph(data.narrative_processing_insights.strip())
540
+ _table(doc, ["Gender", "Number of Agroprocessors"],
541
+ [["Male", data.processors.male], ["Female", data.processors.female]])
542
+ _caption(doc, "Processing distribution according to Gender")
543
+ if data.processors.business_models:
544
+ _bms = [b for b in data.processors.business_models
545
+ if (b.male or b.female or b.cash_as_service
546
+ or b.goods_as_service or b.purchase_raw_materials)]
547
+ if _bms:
548
+ _table(doc, ["Crops Processed", "Male", "Female", "cash as a service",
549
+ "goods as a service", "purchase raw materials"],
550
+ [[b.crop, b.male, b.female, b.cash_as_service,
551
+ b.goods_as_service, b.purchase_raw_materials]
552
+ for b in _bms])
553
+ _caption(doc, "Processing distribution according to business model")
554
+
555
+ # One machinery table per crop group (adapts to any community). When the
556
+ # rows carry the richer columns (power source / rated power / capacity),
557
+ # render those; otherwise keep the compact Machine | Specification | Qty
558
+ # layout (Jaji's reference style).
559
+ groups = []
560
+ for m in data.processors.machinery:
561
+ if m.crop_group not in groups:
562
+ groups.append(m.crop_group)
563
+ for g in groups:
564
+ rows = [m for m in data.processors.machinery if m.crop_group == g]
565
+ # The flat extractor tags rows "processing equipment" — those are the
566
+ # communities (e.g. Abigi) that supply Power Source / Rated Power /
567
+ # Capacity. Jaji-style crop-grouped rows keep the compact layout so
568
+ # their tables stay faithful to that reference.
569
+ rich = (g == "processing equipment")
570
+ has_cap = any(m.capacity for m in rows)
571
+ if rich:
572
+ headers = [g, "Power Source", "Rated Power"]
573
+ if has_cap:
574
+ headers.append("Capacity")
575
+ headers.append("Qty")
576
+ body = []
577
+ for m in rows:
578
+ row = [m.machine, m.power_source, m.rated_power]
579
+ if has_cap:
580
+ row.append(m.capacity)
581
+ row.append(m.count)
582
+ body.append(row)
583
+ _table(doc, headers, body)
584
+ else:
585
+ _machinery_table(doc, g, rows)
586
+ _caption(doc, f"Machinery Profile of {g}")
587
+
588
+ if data.processors.processing_quantities:
589
+ _table(doc, ["Crops Processed", "Total Quantity Processed (Peak)",
590
+ "Total Quantity Processed (Scarce)", "Average Quantity (Peak)",
591
+ "Average Quantity Processed (Scarce)"],
592
+ [[q.crop, f"{q.total_peak:,.0f}", f"{q.total_scarce:,.0f}",
593
+ f"{q.avg_peak:,.0f}", f"{q.avg_scarce:,.1f}"]
594
+ for q in data.processors.processing_quantities])
595
+ _caption(doc, "Processing insight")
596
+
597
+ # CO2 emission assessment for the fuel-powered processing machines.
598
+ co2 = data.processors.co2_assessment
599
+ if co2:
600
+ _heading(doc, "CO\u2082 Emission Assessment", 3)
601
+ body = []
602
+ tot_n = tot_l = tot_c = 0.0
603
+ any_l = any_c = False
604
+ for r in co2:
605
+ litres = "" if r.annual_fuel_litres is None else f"{r.annual_fuel_litres:,.0f}"
606
+ kg = "" if r.co2_kg is None else f"{r.co2_kg:,.2f}"
607
+ body.append([r.machine, r.specification, r.fuel_type,
608
+ r.num_machines, litres, kg])
609
+ tot_n += r.num_machines
610
+ if r.annual_fuel_litres is not None:
611
+ tot_l += r.annual_fuel_litres; any_l = True
612
+ if r.co2_kg is not None:
613
+ tot_c += r.co2_kg; any_c = True
614
+ body.append(["Total", "", "", f"{tot_n:g}",
615
+ f"{tot_l:,.0f}" if any_l else "",
616
+ f"{tot_c:,.2f}" if any_c else ""])
617
+ _table(doc, ["Machine", "Specification", "Fuel Type", "No. of Machines",
618
+ "Annual Fuel Consumption (L)", "CO\u2082 Emissions (kg CO\u2082/year)"],
619
+ body)
620
+ _caption(doc, "Annual CO\u2082 Emissions from Fuel-Powered Engines")
621
+ doc.add_paragraph(
622
+ "Note: IPCC emission factors are 2.31 kg CO\u2082 per litre of petrol "
623
+ "and 2.86 kg CO\u2082 per litre of diesel. Where annual fuel "
624
+ "consumption is not yet available, emissions are pending that input.")
625
+
626
+
627
+ # Mobility
628
+ _heading(doc, "Mobility", 2)
629
+ mob = data.mobility
630
+ _table(doc, ["E-Mobility", str(mob.total)],
631
+ [["Okada", mob.okada], ["Tricycle", mob.tricycle],
632
+ ["Boat", mob.boat]])
633
+ _caption(doc, "Mobility interviewed")
634
+
635
+ def _money_block(label, total, avg, caption):
636
+ if total is None and avg is None:
637
+ return
638
+ _table(doc, [label, "Value (\u20a6)"],
639
+ [["Total", "" if total is None else f"{total:,.2f}"],
640
+ ["Average", "" if avg is None else f"{avg:,.2f}"]])
641
+ _caption(doc, caption)
642
+
643
+ _money_block("Daily Revenue", mob.daily_revenue_total,
644
+ mob.daily_revenue_avg, "Daily Revenue")
645
+ _money_block("Daily Expenses (Fuel)", mob.daily_fuel_expense_total,
646
+ mob.daily_fuel_expense_avg, "Daily Expenses (Fuel)")
647
+ _money_block("Daily Profit", mob.daily_profit_total,
648
+ mob.daily_profit_avg, "Daily Profit")
649
+
650
+ # SME
651
+ _heading(doc, "SME", 2)
652
+ _table(doc, ["SME", str(data.sme.total)],
653
+ [[t.business_type, t.count] for t in data.sme.types])
654
+ _caption(doc, "SME interviewed")
655
+
656
+
657
+ def _fmt_cell(v):
658
+ if v is None:
659
+ return ""
660
+ if isinstance(v, float):
661
+ return f"{v:,.2f}".rstrip("0").rstrip(".") if v % 1 else f"{int(v):,}"
662
+ if isinstance(v, int):
663
+ return f"{v:,}"
664
+ return str(v)
665
+
666
+
667
+ def _equipment_tables(doc, plan, phase_label):
668
+ """Render one table per equipment section, using that section's OWN
669
+ columns (agro-processing and the Swap Station have different headers)."""
670
+ for sec in plan.sections:
671
+ headers = sec.headers or ["Item"]
672
+ body = [[_fmt_cell(v) for v in row] for row in sec.rows]
673
+ if sec.total_row:
674
+ body.append([_fmt_cell(v) for v in sec.total_row])
675
+ _table(doc, headers, body)
676
+ _caption(doc, f"{sec.name} ({phase_label})")
677
+
678
+
679
+ def _proposed_equipment(doc, data):
680
+ _heading(doc, "Proposed PUE Equipment", 1)
681
+ _heading(doc, "ScaleUp Phase", 2)
682
+ _equipment_tables(doc, data.equipment_scaleup, "Scaleup")
683
+ _scaleup = data.equipment_scaleup
684
+ if _scaleup.projection:
685
+ _heading(doc, "Total Energy Projection for Micro Stand Alone System", 3)
686
+ rows = [[p.item, f"{p.kwh:,.2f}", f"{p.six_hour_day:,.2f}"]
687
+ for p in _scaleup.projection]
688
+ rows.append(["TOTAL", f"{(_scaleup.projection_total_kwh or 0):,.2f}",
689
+ f"{(_scaleup.projection_total_six_hour or 0):,.2f}"])
690
+ _table(doc, ["ITEM", "Power Consumption (kWh)", "6-Hour Day Work (kWh)"],
691
+ rows)
692
+ _caption(doc, "Total Energy Projection (Scale-Up Phase)")
693
+ _heading(doc, "Pilot Phase", 2)
694
+ _equipment_tables(doc, data.equipment_pilot, "Pilot")
695
+
696
+ _heading(doc, "Total Energy Projection for PUE Pilot Micro Stand Alone System", 2)
697
+ plan = data.equipment_pilot
698
+ rows = [[p.item, f"{p.kwh:,.2f}", f"{p.six_hour_day:,.2f}"]
699
+ for p in plan.projection]
700
+ rows.append(["TOTAL", f"{(plan.projection_total_kwh or 0):,.2f}",
701
+ f"{(plan.projection_total_six_hour or 0):,.2f}"])
702
+ _table(doc, ["ITEM", "kW/hr", "6 hour day work"], rows)
703
+ _caption(doc, "Total Energy Projection \u2013 Pilot")
704
+
705
+
706
+ def _infrastructure(doc, data):
707
+ _heading(doc, "Detailed Pilot Infrastructure Specifications", 1)
708
+ _heading(doc, "AgroHub Pilot Infrastructure Specifications", 2)
709
+ doc.add_paragraph(T.AGROHUB_SPEC_INTRO)
710
+ for b in T.AGROHUB_SPEC_BULLETS:
711
+ doc.add_paragraph(b, style="List Bullet")
712
+ _heading(doc, "Land & Building Layout", 2)
713
+ for b in T.LAND_BUILDING_LAYOUT:
714
+ doc.add_paragraph(b, style="List Bullet")
715
+ _table(doc, ["Room / Area", "Dimension (m)", "Area (m²)",
716
+ "Notes / IoT Deployment"],
717
+ [list(r) for r in T.INFRA_LAYOUT_TABLE])
718
+ _caption(doc, "Pilot Phase Layout \u2013 IoT Enabled")
719
+ for s in T.STRUCTURAL_FEATURES:
720
+ doc.add_paragraph(s, style="List Bullet")
721
+ _heading(doc, "Cooperative Governance & Operational Arrangement", 2)
722
+ _table(doc, ["Structure", "Description"],
723
+ [list(r) for r in T.COOP_GOVERNANCE])
724
+ _caption(doc, "Cooperative Governance & Operational Arrangement")
725
+ _heading(doc, "Safety and Compliance", 2)
726
+ _table(doc, ["Aspect", "Implementation"],
727
+ [list(r) for r in T.SAFETY_COMPLIANCE])
728
+ _caption(doc, "Safety and Compliance")
729
+ _heading(doc, "Security Features", 2)
730
+ for s in T.SECURITY_FEATURES:
731
+ doc.add_paragraph(s, style="List Bullet")
732
+ # Figure (placeholder box with a correctly-numbered caption)
733
+ p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
734
+ p.add_run("[ " + T.FIGURE_AGROHUB + " image ]").italic = True
735
+ counters = doc._seq_counters
736
+ counters["Figure"] = counters.get("Figure", 0) + 1
737
+ fp = doc.add_paragraph(); fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
738
+ style = "Caption" if "Caption" in [s.name for s in doc.styles] else None
739
+ if style:
740
+ fp.style = style
741
+ fp.add_run("Figure ")
742
+ _field(fp, "SEQ Figure \\* ARABIC", str(counters["Figure"]))
743
+ fp.add_run(f": {T.FIGURE_AGROHUB}")
744
+
745
+
746
+ def _boq_budget(doc, data):
747
+ _heading(doc, "BOQ & Budget", 1)
748
+ doc.add_paragraph("The Bill of Quantity is for the Agrohub model; budgets "
749
+ "for other models are available in the financial model "
750
+ "worksheet. The breakdown of the budget is listed below.")
751
+ for s in data.budget.sections:
752
+ _heading(doc, s.name, 2)
753
+ body = [[i.item, i.details, i.quantity, _money(i.unit_price),
754
+ _money(i.total_price)] for i in s.items]
755
+ body.append(["TOTAL", "", "", "", _money(s.total)])
756
+ _table(doc, ["Item", "Details", "Quantity", "Unit Price",
757
+ "Total Price"], body)
758
+ _caption(doc, f"{s.name} budget")
759
+ _heading(doc, "Grand Total", 2)
760
+ rows = [[i + 1, s.name, _money(s.total)]
761
+ for i, s in enumerate(data.budget.sections)]
762
+ rows.append(["", "TOTAL", _money(data.budget.grand_total)])
763
+ _table(doc, ["S/N", "ITEM", "PRICE (₦)"], rows)
764
+ _caption(doc, "Total PUE Pilot budget")
765
+
766
+
767
+ def _financial_model(doc, data):
768
+ _heading(doc, "Financial Model", 1)
769
+ c = data.community
770
+ doc.add_paragraph(f"For the financial model for the deployment of PUE in "
771
+ f"{c.name} community, several models were tested to find "
772
+ f"the best fit which ensures affordability while providing "
773
+ f"the appropriate productive-use technology for this pilot "
774
+ f"phase. The models are stated below.")
775
+ _table(doc, ["Business Model", "Explanation"],
776
+ [list(r) for r in T.IMPLEMENTATION_MODELS_FULL])
777
+ _caption(doc, "Various Implementation models")
778
+
779
+ _heading(doc, "Monthly Revenue", 2)
780
+ for m in data.financial_models:
781
+ body = [[m.model_name if i == 0 else "", r.beneficiary, r.quantity,
782
+ f"{r.timeframe_months:.0f}", f"{r.amount_monthly:,.0f}"]
783
+ for i, r in enumerate(m.rows)]
784
+ _table(doc, ["Business Model", "Beneficiaries", "Quantity",
785
+ "Timeframe (months)", "Amount (monthly)"], body)
786
+ _caption(doc, m.model_name)
787
+
788
+
789
+ def _revenue_per_user(doc, data):
790
+ _heading(doc, "Revenue Per User", 1)
791
+ doc.add_paragraph("Projected revenue per user for the mini-grid and the "
792
+ "contribution of each category is shown below.")
793
+ if data.revenue_per_user:
794
+ _table(doc, ["Category", "Effective Daily Demand (kWh)",
795
+ "Effective Annual Demand (kWh)", "No of Beneficiaries",
796
+ "Average kWh Tariff", "Daily Revenue per Category (#/kWh)",
797
+ "Daily Average Revenue (#/kWh)"],
798
+ [[r.category, f"{r.effective_daily_demand_kwh:,.2f}",
799
+ f"{r.effective_annual_demand_kwh:,.2f}", r.beneficiaries,
800
+ f"{r.avg_tariff:,.3f}", f"{r.daily_revenue_category:,.2f}",
801
+ f"{r.daily_avg_revenue:,.2f}"] for r in data.revenue_per_user])
802
+ else:
803
+ _table(doc, ["Category", "Effective Daily Demand (kWh)",
804
+ "Effective Annual Demand (kWh)", "No of Beneficiaries",
805
+ "Average kWh Tariff", "Daily Revenue per Category (#/kWh)",
806
+ "Daily Average Revenue (#/kWh)"], [["", "", "", "", "", "", ""]])
807
+ _caption(doc, "Average revenue per user")
808
+
809
+
810
+ def _implementation_plan(doc, c):
811
+ _heading(doc, "Implementation Plan", 1)
812
+ doc.add_paragraph(T.IMPLEMENTATION_PLAN_INTRO.format(community=c.name))
813
+ _table(doc, ["S/N", "Title", "Responsibility", "Duration", "Timeline"],
814
+ [list(r) for r in T.IMPLEMENTATION_PLAN])
815
+ _caption(doc, "Implementation plan")
816
+
817
+
818
+ def _conditions_for_scaleup(doc):
819
+ _heading(doc, "Conditions for Scaleup", 1)
820
+ doc.add_paragraph(T.SCALEUP_INTRO)
821
+ for section_title, items in T.SCALEUP_SECTIONS:
822
+ _heading(doc, section_title, 2)
823
+ for sub_title, bullets in items:
824
+ _heading(doc, sub_title, 3)
825
+ for b in bullets:
826
+ doc.add_paragraph(b, style="List Bullet")
827
+ _heading(doc, "Practical \u201cGo/No-Go\u201d Scale-Up Gate", 2)
828
+ doc.add_paragraph("Scale-up is activated when ALL conditions below are met:")
829
+ for g in T.SCALEUP_GATE:
830
+ doc.add_paragraph(g, style="List Bullet")
831
+
832
+
833
+ def _mer(doc, c):
834
+ _heading(doc, "Monitoring, Evaluation & Reporting (MER)", 1)
835
+ doc.add_paragraph(T.MER_INTRO.format(community=c.name))
836
+ _heading(doc, "Key Performance Indicators (KPIs)", 2)
837
+ _table(doc, ["KPI Category", "Indicator", "Frequency"], T.KPIS)
838
+ _caption(doc, "KPI")
839
+ _heading(doc, "Data Collection and Reporting Tools", 2)
840
+ _table(doc, ["Tool / System", "Purpose", "Managed By"],
841
+ [list(r) for r in T.DATA_TOOLS])
842
+ _caption(doc, "Data Collection and Reporting Tools")
843
+ _heading(doc, "Feedback Loops", 2)
844
+ _table(doc, ["S/N", "Item", "Who", "Frequency", "Purpose"],
845
+ [list(r) for r in T.FEEDBACK_LOOPS])
846
+ _caption(doc, "Feedback Loops")
847
+
848
+
849
+ def _risks(doc, c):
850
+ _heading(doc, "Risk Assessment and Mitigation", 1)
851
+ doc.add_paragraph(f"Deploying Productive Use of Energy (PUE) infrastructure "
852
+ f"in {c.name} presents several operational, financial, "
853
+ f"social, and institutional risks. This section outlines "
854
+ f"the key risks anticipated and the strategic mitigation "
855
+ f"measures integrated into the implementation design.")
856
+ _heading(doc, "Risks Identified", 2)
857
+ _table(doc, ["Risk Category", "Description"], T.RISKS_IDENTIFIED)
858
+ _caption(doc, "Risks Identified")
859
+ _heading(doc, "Mitigation Measures", 2)
860
+ _table(doc, ["Risk", "Mitigation Strategy"], T.MITIGATION_MEASURES)
861
+ _caption(doc, "Mitigation Measures")
862
+
863
+
864
+ def _impact(doc, data):
865
+ _heading(doc, "Proposed Impact of the PUE on the Community", 1)
866
+ c = data.community
867
+ doc.add_paragraph(f"The implementation of Productive Use of Energy (PUE) "
868
+ f"infrastructure and equipment in {c.name} is projected to "
869
+ f"catalyze a range of socioeconomic, environmental, and "
870
+ f"institutional benefits, addressing long-standing "
871
+ f"challenges in agro-processing, transportation, energy "
872
+ f"access, and rural livelihoods.")
873
+ rows = ([[r.impact_area, r.metric, r.projected_outcome]
874
+ for r in data.projected_impact]
875
+ or [list(r) for r in T.PROJECTED_IMPACT_DEFAULT])
876
+ _table(doc, ["Impact Area", "Metric", "Projected Outcome"], rows)
877
+ _caption(doc, "Summary of Projected Outcomes by Year 1")
878
+
879
+
880
+ def _sdgs(doc):
881
+ _heading(doc, "Contribution to the Sustainable Development Goals (SDGs)", 1)
882
+ doc.add_paragraph("The project directly contributes to several SDGs by "
883
+ "integrating clean energy into productive rural "
884
+ "enterprises.")
885
+ _table(doc, ["SDG", "Goal Description", "Project Contribution"], T.SDGS)
886
+ _caption(doc, "Project Alignment with SDGs")
887
+
888
+
889
+ def _challenges(doc):
890
+ _heading(doc, "Challenges And Recommendations", 1)
891
+ _heading(doc, "Key Challenges", 2)
892
+ _table(doc, ["Lesson Learned", "Recommended Best Practice"],
893
+ [list(r) for r in T.KEY_CHALLENGES])
894
+ _caption(doc, "Key Challenges and recommendations")
895
+ _heading(doc, "Forecasted Implementation Challenge", 2)
896
+ _table(doc, ["Lesson Learned", "Recommended Best Practice"],
897
+ [list(r) for r in T.FORECASTED_CHALLENGES])
898
+ _caption(doc, "Forecasted Implementation Challenge and recommendation")
899
+
900
+
901
+ def _ai_disclosure(doc):
902
+ _heading(doc, "Use of Artificial Intelligence (AI) in Report Development", 1)
903
+ block = T.AI_DISCLOSURE
904
+ for para in block["intro"]:
905
+ doc.add_paragraph(para)
906
+ for label, text in block["sources"]:
907
+ p = doc.add_paragraph(style="List Bullet")
908
+ run = p.add_run(f"{label}: ")
909
+ run.bold = True
910
+ p.add_run(text)
911
+ doc.add_paragraph(block["closing"])
pue_report_agent/schema.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ schema.py
3
+ =========
4
+ The canonical, validated representation of everything a PUE report needs.
5
+
6
+ This is the contract between extraction (reading the master sheet) and
7
+ generation (writing the report). The extractor's only job is to fill one of
8
+ these objects; the generator's only job is to turn a filled one into a
9
+ document. Nothing else in the system should read the raw spreadsheet.
10
+
11
+ Design notes derived from reviewing the Jaji and Kwarua Tasha pairs:
12
+
13
+ * `minigrid` / `microgrid` capacities do NOT come from the survey data. They
14
+ are supplied by the mini-grid developer and live in the Key Findings sheet
15
+ as hardcoded values (and are sometimes stale). They are modelled as optional
16
+ + overridable so the agent can flag/replace them.
17
+ * Several report sections are pure boilerplate (risks, KPIs, scale-up gates,
18
+ SDGs, methodology). They are NOT in this schema at all — they live in
19
+ templates.py. Only community-specific, data-driven content is modelled here.
20
+ * Every field that the report renders as a table is modelled as a list of rows
21
+ so the generator can emit it without re-deriving anything.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Optional
27
+ from pydantic import BaseModel, Field
28
+
29
+
30
+ # --------------------------------------------------------------------------- #
31
+ # Community profile (source: ESG sheet + Key Findings)
32
+ # --------------------------------------------------------------------------- #
33
+ class CommunityProfile(BaseModel):
34
+ name: str
35
+ lga: str
36
+ state: str
37
+ latitude: float
38
+ longitude: float
39
+
40
+ topography_climate: str = ""
41
+ land_use: str = ""
42
+ population_estimate: Optional[int] = None
43
+ households: Optional[int] = None
44
+ major_ethnic_groups: str = ""
45
+ languages_spoken: str = ""
46
+ key_economic_activities: str = ""
47
+ key_agro_commodities: str = ""
48
+ processing_methods: str = ""
49
+ num_agro_processors: Optional[int] = None
50
+ farmers_cooperative: str = ""
51
+ infrastructure: str = ""
52
+ security_social_risks: str = ""
53
+
54
+ # Free-text fields the ESG sheet supplies that feed the narrative overview
55
+ landmarks: str = ""
56
+ grid_status: str = ""
57
+ network_available: str = ""
58
+ water_sources: str = ""
59
+ community_sentiment: str = ""
60
+ land_ownership: str = ""
61
+ community_structures: str = ""
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Energy system capacities (source: Key Findings — developer-supplied)
66
+ # --------------------------------------------------------------------------- #
67
+ class EnergySystemSpec(BaseModel):
68
+ """Used for both the mini-grid and the micro stand-alone system."""
69
+ solar_pv: str = "" # e.g. "6.45 MWp"
70
+ inverter: str = "" # e.g. "6.93 MW" (PV Inverter)
71
+ battery_storage: str = "" # e.g. "10 MWh"
72
+ annual_consumption: str = "" # e.g. "5 MW" / "47,692.8 kWh"
73
+ distribution_metrics: list["DistributionLine"] = Field(default_factory=list)
74
+ developer_supplied: bool = True
75
+ needs_review: bool = False # set True when value looks stale/templated
76
+
77
+
78
+ class DistributionLine(BaseModel):
79
+ end_user: str # "Residential", "Agricultural Processing", etc.
80
+ percentage: str # "76%"
81
+ description: str = ""
82
+
83
+
84
+ # --------------------------------------------------------------------------- #
85
+ # Interview counts (source: Key Findings "People interviewed")
86
+ # --------------------------------------------------------------------------- #
87
+ class InterviewCounts(BaseModel):
88
+ total: int
89
+ farmers: int
90
+ agroprocessors: int
91
+ sme: int
92
+ mobility: int
93
+
94
+
95
+ # --------------------------------------------------------------------------- #
96
+ # Farmers (source: Farmers Analysis Sheet + Key Findings crop table)
97
+ # --------------------------------------------------------------------------- #
98
+ class CropRow(BaseModel):
99
+ crop: str
100
+ farming_households: int
101
+ percentage: float
102
+ annual_production_interviewed: float
103
+ estimated_annual_production_community: float
104
+ total_area_planted_hectares: float
105
+
106
+
107
+ class FarmerAnalysis(BaseModel):
108
+ male: int
109
+ female: int
110
+ crops: list[CropRow] = Field(default_factory=list)
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # Processors (source: Processors Analysis Sheet + Processors raw)
115
+ # --------------------------------------------------------------------------- #
116
+ class MachineRow(BaseModel):
117
+ crop_group: str # "maize", "rice", "saw mill", "multiple grains"
118
+ machine: str
119
+ specification: str
120
+ count: int
121
+ # Richer columns some communities provide (e.g. Abigi). Parsed from the
122
+ # specification where possible; blank when the sheet doesn't supply them.
123
+ power_source: str = "" # "Petrol", "Diesel", "Electric", "Manual"
124
+ rated_power: str = "" # "13hp", "15 HP electric motor", ...
125
+ capacity: str = "" # "20 bags", "348 Palm kernel bags", ...
126
+
127
+
128
+ class ProcessingQtyRow(BaseModel):
129
+ crop: str
130
+ total_peak: float
131
+ total_scarce: float
132
+ avg_peak: float
133
+ avg_scarce: float
134
+
135
+
136
+ # --------------------------------------------------------------------------- #
137
+ # CO2 emission assessment (fuel-powered processing machines)
138
+ # --------------------------------------------------------------------------- #
139
+ class CO2EmissionRow(BaseModel):
140
+ """One fuel-powered machine line in the CO2 assessment.
141
+
142
+ co2_kg is computed deterministically as annual_fuel_litres * the IPCC
143
+ emission factor for the fuel (2.31 kg CO2/L petrol, 2.86 kg CO2/L diesel).
144
+ annual_fuel_litres is left None when the sheet doesn't supply per-machine
145
+ fuel consumption (in which case co2_kg is also None and a warning is added).
146
+ """
147
+ machine: str
148
+ specification: str = ""
149
+ fuel_type: str = ""
150
+ num_machines: int = 0
151
+ annual_fuel_litres: Optional[float] = None
152
+ co2_kg: Optional[float] = None
153
+
154
+
155
+ class BusinessModelRow(BaseModel):
156
+ crop: str
157
+ male: int
158
+ female: int
159
+ cash_as_service: int
160
+ goods_as_service: int
161
+ purchase_raw_materials: int
162
+
163
+
164
+ class ProcessorAnalysis(BaseModel):
165
+ male: int
166
+ female: int
167
+ business_models: list[BusinessModelRow] = Field(default_factory=list)
168
+ machinery: list[MachineRow] = Field(default_factory=list)
169
+ processing_quantities: list[ProcessingQtyRow] = Field(default_factory=list)
170
+ co2_assessment: list[CO2EmissionRow] = Field(default_factory=list)
171
+
172
+
173
+ # --------------------------------------------------------------------------- #
174
+ # Mobility (source: E-Mobility Analysis Sheet)
175
+ # --------------------------------------------------------------------------- #
176
+ class MobilityAnalysis(BaseModel):
177
+ total: int
178
+ okada: int
179
+ tricycle: int
180
+ boat: int
181
+ daily_revenue_total: Optional[float] = None
182
+ daily_revenue_avg: Optional[float] = None
183
+ daily_fuel_expense_total: Optional[float] = None
184
+ daily_fuel_expense_avg: Optional[float] = None
185
+ daily_profit_total: Optional[float] = None
186
+ daily_profit_avg: Optional[float] = None
187
+ daily_operation_hours: Optional[float] = None
188
+
189
+
190
+ # --------------------------------------------------------------------------- #
191
+ # SME (source: SME Analysis Sheet)
192
+ # --------------------------------------------------------------------------- #
193
+ class SMETypeRow(BaseModel):
194
+ business_type: str
195
+ count: int
196
+
197
+
198
+ class SMEAnalysis(BaseModel):
199
+ total: int
200
+ types: list[SMETypeRow] = Field(default_factory=list)
201
+
202
+
203
+ # --------------------------------------------------------------------------- #
204
+ # Proposed equipment (source: Proposed Pilot / ScaleUp PUE Equipment)
205
+ # --------------------------------------------------------------------------- #
206
+ class EquipmentSection(BaseModel):
207
+ """One equipment block with its OWN native columns.
208
+
209
+ Equipment blocks differ by section: agro-processing uses
210
+ Machine|Capacity|Power rating|Quantity|Energy Source, while the Swap Station
211
+ uses E-Mobility Vehicles|Capacity|Quantity|Charging Rate|Charging Time. We
212
+ keep each block's real headers and rows so nothing is mis-mapped, and a
213
+ recomputed TOTAL row is added for the numeric columns."""
214
+ name: str
215
+ headers: list[str] = Field(default_factory=list)
216
+ rows: list[list] = Field(default_factory=list) # raw cell values
217
+ total_row: list = Field(default_factory=list) # filled by calc
218
+
219
+
220
+ class EnergyProjectionRow(BaseModel):
221
+ item: str
222
+ kwh: float
223
+ six_hour_day: float
224
+
225
+
226
+ class EquipmentPlan(BaseModel):
227
+ phase: str # "Pilot" or "ScaleUp"
228
+ sections: list[EquipmentSection] = Field(default_factory=list)
229
+ projection: list[EnergyProjectionRow] = Field(default_factory=list)
230
+ projection_total_kwh: Optional[float] = None
231
+ projection_total_six_hour: Optional[float] = None
232
+
233
+
234
+ # --------------------------------------------------------------------------- #
235
+ # Budget (source: Budget sheet)
236
+ # --------------------------------------------------------------------------- #
237
+ class BudgetLineItem(BaseModel):
238
+ section: str # "Energy Infrastructure", "Swap Station", etc.
239
+ item: str
240
+ details: str = ""
241
+ quantity: Optional[float] = None
242
+ unit_price: Optional[float] = None
243
+ total_price: Optional[float] = None
244
+
245
+
246
+ class BudgetSection(BaseModel):
247
+ name: str
248
+ items: list[BudgetLineItem] = Field(default_factory=list)
249
+ total: Optional[float] = None
250
+
251
+
252
+ class Budget(BaseModel):
253
+ sections: list[BudgetSection] = Field(default_factory=list)
254
+ grand_total: Optional[float] = None
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # Financial model (source: Summary + Individual/Agrohub Model sheets)
259
+ # --------------------------------------------------------------------------- #
260
+ class FinancialRow(BaseModel):
261
+ beneficiary: str # "Farming Equipment", "Mobility", "SME"
262
+ quantity: float
263
+ timeframe_months: float
264
+ amount_monthly: float
265
+
266
+
267
+ class FinancialModel(BaseModel):
268
+ model_name: str # "Individual Model - New Equipment", etc.
269
+ rows: list[FinancialRow] = Field(default_factory=list)
270
+ total_monthly: Optional[float] = None
271
+
272
+
273
+ # --------------------------------------------------------------------------- #
274
+ # Revenue per user (source: Key Findings — developer-supplied)
275
+ # --------------------------------------------------------------------------- #
276
+ class RevenuePerUserRow(BaseModel):
277
+ category: str
278
+ effective_daily_demand_kwh: float
279
+ effective_annual_demand_kwh: float
280
+ beneficiaries: int
281
+ avg_tariff: float
282
+ daily_revenue_category: float
283
+ daily_avg_revenue: float
284
+
285
+
286
+ # --------------------------------------------------------------------------- #
287
+ # Projected impact (source: Key Findings impact table)
288
+ # --------------------------------------------------------------------------- #
289
+ class ImpactRow(BaseModel):
290
+ impact_area: str
291
+ metric: str
292
+ projected_outcome: str
293
+
294
+
295
+ # --------------------------------------------------------------------------- #
296
+ # Processor / SME distance to minigrid (source: Key Findings distance table)
297
+ # --------------------------------------------------------------------------- #
298
+ class DistanceRow(BaseModel):
299
+ name: str
300
+ site_lat: Optional[float] = None
301
+ site_long: Optional[float] = None
302
+ minigrid_gps: str = ""
303
+ distance_km: Optional[float] = None
304
+
305
+
306
+ # --------------------------------------------------------------------------- #
307
+ # Top-level container
308
+ # --------------------------------------------------------------------------- #
309
+ class PUEReportData(BaseModel):
310
+ community: CommunityProfile
311
+ minigrid: EnergySystemSpec = Field(default_factory=EnergySystemSpec)
312
+ microgrid: EnergySystemSpec = Field(default_factory=EnergySystemSpec)
313
+ interviews: InterviewCounts
314
+ farmers: FarmerAnalysis
315
+ processors: ProcessorAnalysis
316
+ mobility: MobilityAnalysis
317
+ sme: SMEAnalysis
318
+ equipment_pilot: EquipmentPlan
319
+ equipment_scaleup: EquipmentPlan
320
+ budget: Budget
321
+ financial_models: list[FinancialModel] = Field(default_factory=list)
322
+ revenue_per_user: list[RevenuePerUserRow] = Field(default_factory=list)
323
+ projected_impact: list[ImpactRow] = Field(default_factory=list)
324
+ processor_distances: list[DistanceRow] = Field(default_factory=list)
325
+
326
+ # AI-generated prose (filled by the generation chains, not the extractor)
327
+ narrative_overview: str = ""
328
+ narrative_demographic: str = ""
329
+ narrative_processing_insights: str = ""
330
+
331
+ # Provenance / QA — issues the extractor flags for human review
332
+ warnings: list[str] = Field(default_factory=list)
333
+
334
+
335
+ # Pydantic v2 forward-ref resolution for the self-referential model.
336
+ EnergySystemSpec.model_rebuild()
pue_report_agent/templates.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ templates.py
3
+ ============
4
+ The report sections that are effectively identical across communities. From the
5
+ Jaji/Kwarua Tasha comparison these are byte-for-byte boilerplate aside from the
6
+ community name and the mini-grid developer, so they should NEVER be sent to an
7
+ LLM — that only risks hallucination and wastes tokens. They are stored as
8
+ format strings and filled deterministically.
9
+
10
+ If a future report needs to vary one of these, lift it out into the schema and
11
+ add a generation chain for it. Until then, keep it here.
12
+ """
13
+
14
+ INTRODUCTION = {
15
+ "lead": "This study evaluates the needs, viability, profitability, "
16
+ "implementation, and long-term sustainability of Productive Use of Energy "
17
+ "(PUE) investments in {community} community. The assessment employed a "
18
+ "rigorous methodology that combined desktop research, survey design, "
19
+ "field-based needs assessments, equipment design, financial modeling, and "
20
+ "project management. Key inputs were provided by the Distributed Renewable "
21
+ "Energy Enhancement Facility (DREEF), {developer} (the mini-grid "
22
+ "developer), the PUE team and local community leaders. These contributions "
23
+ "included technical data, funding support, and facilitation of access to "
24
+ "community stakeholders and proposed beneficiaries.",
25
+ "objectives_lead": "The specific objectives of the study were to:",
26
+ "objectives": [
27
+ "Identify potential PUE users, their production/processing capacity, "
28
+ "expenses spent on fossil fuel and their power capacity requirements.",
29
+ "Assess the socio-economic and commercial viability of PUE "
30
+ "opportunities.",
31
+ "Propose tailored PUE solutions.",
32
+ "Evaluate users' willingness to switch from fossil fuels to "
33
+ "energy-efficient PUE equipment and to connect to the proposed "
34
+ "mini-grid for increased productivity.",
35
+ "Develop an implementation plan for deploying these solutions in the "
36
+ "community.",
37
+ ],
38
+ "closing": "This report provides actionable insights to guide the design "
39
+ "and deployment of productive use of energy technologies, strengthen rural "
40
+ "enterprises, and align with Nigeria's broader development priorities, "
41
+ "while contributing to the achievement of the Sustainable Development "
42
+ "Goals (SDGs).",
43
+ }
44
+
45
+ ABOUT_CONTRACTOR = {
46
+ "body": "This report is carried out by the PUE team of DREEF with "
47
+ "collaboration from other key expert teams, namely the Technical team, "
48
+ "Legal team, Financial team, Procurement team, and ESG team.",
49
+ "link_lead": "For more detailed information about DREEF, kindly click ",
50
+ "link_text": "HERE",
51
+ "link_url": "https://dreef.org/",
52
+ "link_tail": ".",
53
+ }
54
+
55
+ METHODOLOGY = """\
56
+ A desktop review and gap analysis was conducted to establish baseline \
57
+ information on the community and its mini-grid development prospects. This \
58
+ included assessments of energy demand, household demographics, existing energy \
59
+ sources, environmental and infrastructural factors, financial viability, and \
60
+ regulatory frameworks. Where information was lacking, gaps were identified for \
61
+ follow-up during fieldwork."""
62
+
63
+ # Risk register — identical across both reviewed reports.
64
+ RISKS_IDENTIFIED = [
65
+ ("Low Equipment Adoption", "Users may be unwilling to switch from manual or diesel-powered methods due to habit or cost fears."),
66
+ ("Machine Downtime", "Equipment may break down frequently due to poor maintenance, improper use, or lack of spare parts."),
67
+ ("Electricity Reliability", "Mini-grid overload or poor solar sizing could affect equipment performance or user satisfaction."),
68
+ ("Theft and Vandalism", "Equipment, batteries, or solar panels may be stolen or damaged, especially at night or in isolated areas."),
69
+ ("Lack of Operator Skills", "Improper usage may cause damage or reduce equipment lifespan."),
70
+ ("Financial Default", "Operators may default on payment models (lease-to-own, usage fees, subscriptions)."),
71
+ ("Cooperative Failure", "Poor management or conflicts can collapse cooperatives managing shared equipment."),
72
+ ("Climate Disruptions", "Heavy rains or flooding may affect logistics, deployment schedules, or equipment use."),
73
+ ("Community Resistance", "Resistance to cooperative models or shared infrastructure from individuals preferring personal ownership."),
74
+ ]
75
+
76
+ MITIGATION_MEASURES = [
77
+ ("Low Equipment Adoption", "Community sensitization sessions before deployment; early pilot demonstrations with lead users; flexible financing (pay-as-you-use or lease-to-own)."),
78
+ ("Machine Downtime", "Operator and technician training prior to handover; preventive maintenance schedules; spare parts kits stored locally; equipment logbooks for tracking faults."),
79
+ ("Electricity Reliability", "System design includes buffer for peak loads; load staggering strategy; battery banks and backup generation in hybrid systems."),
80
+ ("Theft and Vandalism", "Secure installation (anchor bolts, enclosures); community-led equipment protection committees; asset insurance for high-value machines."),
81
+ ("Lack of Operator Skills", "Pre-deployment technical training; step-by-step visual SOPs; on-call technical support; quarterly refresher training."),
82
+ ("Financial Default", "Equipment usage tied to cooperative performance; usage monitoring via IoT; group guarantees or staggered asset handover; revenue-based repayment models."),
83
+ ("Cooperative Failure", "External facilitation and capacity-building support for 12 months; strong bylaws and conflict resolution frameworks; monthly governance reviews."),
84
+ ("Climate Disruptions", "Site selection considers drainage and elevation; weather-resistant installations; deployment flexibility during wet season."),
85
+ ("Community Resistance", "Choice between individual and cooperative models; pilots to test both models; transparent beneficiary selection and inclusive engagement."),
86
+ ]
87
+
88
+ KPIS = [
89
+ ("Energy Utilization", "kWh consumed per equipment category (daily/monthly)", "Monthly"),
90
+ ("Energy Utilization", "Peak load vs average load per site", "Monthly"),
91
+ ("Operational", "Machine uptime and downtime logs (hrs/month)", "Weekly"),
92
+ ("Operational", "Frequency of maintenance or repairs", "Monthly"),
93
+ ("Operational", "Processing rate and quantity", "Daily"),
94
+ ("Economic Impact", "Increase in monthly revenue per processor (₦)", "Quarterly"),
95
+ ("Economic Impact", "Monthly fuel savings for e-mobility and agro-processors (₦)", "Monthly"),
96
+ ("Social Impact", "Number of jobs created (technicians, operators, riders)", "Quarterly"),
97
+ ("Social Impact", "Number of women/youths trained and engaged", "Quarterly"),
98
+ ("Adoption", "Number of active PUE users", "Monthly"),
99
+ ("Adoption", "Number of cooperative members enrolled", "Monthly"),
100
+ ]
101
+
102
+ SDGS = [
103
+ ("SDG 7", "Affordable and Clean Energy", "Deployment of solar mini-grids and reduction in fossil fuel usage"),
104
+ ("SDG 8", "Decent Work and Economic Growth", "Job creation and promotion of local agro-based industries"),
105
+ ("SDG 5", "Gender Equality", "Inclusion of women in employment and entrepreneurial roles"),
106
+ ]
107
+
108
+ IMPLEMENTATION_MODELS = [
109
+ ("Individual Model - New Equipment", "Beneficiaries lease newly procured equipment individually on a lease-to-own basis. Each beneficiary is solely responsible for repayment and eventual ownership."),
110
+ ("Individual Model - Retrofit", "Existing fossil-fuel equipment is retrofitted to electric power. Beneficiaries pay for the upgrade in installments over the loan repayment period."),
111
+ ("Agrohub Model", "A shared agricultural hub is established; beneficiaries collectively lease and manage the equipment as a cooperative under a lease-to-own arrangement."),
112
+ ("Hybrid Model - New Equipment", "A shared hub used by farmer cooperatives (collective lease) and individual agroprocessors (individual lease)."),
113
+ ("Agrohub Model + Grant", "As the Agrohub Model, but a grant covers the cost of constructing the hub structure, reducing the financial burden on beneficiaries."),
114
+ ]
115
+
116
+ AI_DISCLOSURE = {
117
+ "intro": [
118
+ "In the preparation of this report, Artificial Intelligence (AI) was "
119
+ "applied primarily as a language support and structuring tool. AI was "
120
+ "used to improve the clarity, readability, and grammatical arrangement "
121
+ "of the document to ensure that the information presented was coherent "
122
+ "and professionally communicated.",
123
+ "It is important to emphasize that the substantive content of the "
124
+ "report was not generated by AI. The data and insights were drawn from "
125
+ "multiple reliable sources, including:",
126
+ ],
127
+ "sources": [
128
+ ("Field Data", "Primary information collected through surveys, "
129
+ "assessments, and stakeholder engagements."),
130
+ ("Recommendations", "Developed based on the experience and expertise "
131
+ "of subject matter specialists."),
132
+ ("Budget Estimates", "Sourced directly from Original Equipment "
133
+ "Manufacturers (OEMs)."),
134
+ ("Financial Models", "Designed and validated by the financial team."),
135
+ ("Technical Inputs", "Provided by the technical team with "
136
+ "sector-specific knowledge."),
137
+ ],
138
+ "closing": "AI therefore played a supporting role in enhancing the "
139
+ "presentation of information, while the core knowledge, data, and analysis "
140
+ "were derived from fieldwork, expert judgment, and technical and financial "
141
+ "teams.",
142
+ }
143
+
144
+
145
+ def fill_intro(community: str, developer: str = "the mini-grid developer") -> str:
146
+ return INTRODUCTION.format(community=community, developer=developer)
147
+
148
+
149
+ # =========================================================================== #
150
+ # TEMPLATE-FAITHFUL BOILERPLATE (verbatim from the Akotogbo PUE template)
151
+ # These are the sections that are identical across communities. Only the
152
+ # {community} / {developer} tokens vary. Everything else renders as-is so the
153
+ # agent's output matches the reference template's tone and wording exactly.
154
+ # =========================================================================== #
155
+
156
+ TITLE_BLOCK = {
157
+ "title": "{community_upper} PRODUCTIVE USE OF ENERGY REPORT",
158
+ "subtitle_lines": [
159
+ "Presented by the",
160
+ "Productive Use of Energy Team",
161
+ "of",
162
+ "Distributed Renewable Energy Enhancement Facility",
163
+ ],
164
+ }
165
+
166
+ METHODOLOGY_HEADING = ("Desktop review, Gap Analysis of Preliminary "
167
+ "information and field management activities")
168
+
169
+ ENERGY_SUBSTITUTION = [ # Table 3 — Use Case / Current Source / Replacement
170
+ ("Household lighting", "Kerosene lamps, petrol generators",
171
+ "Solar electricity"),
172
+ ("Grinding (cassava); Digesting (Oilpalm)", "Diesel/petrol engines",
173
+ "PUE machines"),
174
+ ("SME", "Small petrol generators", "Reliable solar mini-grid power"),
175
+ ("Mobility", "Petrol powered two-wheelers", "Electric powered two-wheelers"),
176
+ ]
177
+
178
+ # --- Detailed Pilot Infrastructure Specifications (boilerplate prose) ------- #
179
+ AGROHUB_SPEC_INTRO = (
180
+ "Each AgroHub functions as a centralized processing facility equipped with "
181
+ "electric-powered machinery tailored to the specific crop value chains. "
182
+ "The hubs will be:")
183
+ AGROHUB_SPEC_BULLETS = [
184
+ "Owned by the SPV",
185
+ "Managed by the cooperative",
186
+ "Powered by a stand-alone system",
187
+ "Modular, allowing expansion to accommodate additional machines or "
188
+ "processing lines during scale-up phases.",
189
+ ]
190
+ LAND_BUILDING_LAYOUT = [
191
+ "Total Land Area: One plot = 15m x 30m = 450m²",
192
+ "Sunbread size — External Dimensions: 10ft by 10ft container",
193
+ "Internal Floor Area (Inside): 2.9 m × 2.9 m ≈ 8.41 m²",
194
+ "Panel Land Size: Half plot of land = 15m x 15m = 225m²",
195
+ "Agrohub — Pilot Hub Facility: 3.03m x 3.03m",
196
+ ]
197
+ INFRA_LAYOUT_TABLE = [ # Table: Pilot Phase Layout – IoT Enabled (indicative)
198
+ ("Primary Processing Machine (e.g. grater/mill)", "0.7m x 1.5m", "1.05",
199
+ "Power: ~5.5 kW; IoT: RPM, vibration, energy, temp sensors; mounted near "
200
+ "motor/hopper."),
201
+ ("Secondary Processing Machine (e.g. presser/polisher)", "0.8m × 1.5m", "1.2",
202
+ "Power: ~3.5 kW; IoT: throughput, motor temperature, load sensors."),
203
+ ]
204
+ STRUCTURAL_FEATURES = [
205
+ "Structural Features: Reinforced concrete slab (150–250 mm depending on wet "
206
+ "zone), oil-resistant epoxy flooring, graded to drains with oil "
207
+ "interceptors, silt traps, and stainless-steel piping.",
208
+ "Roof: Steel truss with long-span metal sheeting, insulation, and ridge "
209
+ "vents for heat and humidity removal.",
210
+ "Electrical: 3-phase distribution rated ≥ 30 kW with surge protection, "
211
+ "per-machine breakers, emergency stop buttons, and IoT energy meters.",
212
+ "Access & Vehicle Movement: Loading bay 8 m × 3 m for trucks and trailers.",
213
+ ]
214
+
215
+ COOP_GOVERNANCE = [ # Table — Structure / Description
216
+ ("Cooperative Ownership",
217
+ "Registered multipurpose cooperative owns the AgroHub asset"),
218
+ ("Management Committee",
219
+ "5-person committee: Chair, Secretary, Treasurer, O&M Rep, Machine "
220
+ "Operator Supervisor"),
221
+ ("Usage Fee System", "Pay-per-use and revenue-share model (₦/kg and % per "
222
+ "batch)"),
223
+ ("Revenue Use", "Split between Lease repayment, maintenance fund, "
224
+ "cooperative savings, machine operator salaries"),
225
+ ("Operation Hours", "8am – 6pm daily (with booking slots to avoid "
226
+ "congestion)"),
227
+ ("Record-Keeping", "Daily usage logs, financial records, machine servicing "
228
+ "checklist"),
229
+ ("Dispute Resolution", "Bylaws + local mediation structure for cooperative "
230
+ "members"),
231
+ ]
232
+
233
+ SAFETY_COMPLIANCE = [ # Table — Aspect / Implementation
234
+ ("Fire Safety", "1 fire extinguisher + 1 fire blanket + sand bucket per hub"),
235
+ ("Machine Safety", "Emergency stop buttons, operator training, use of "
236
+ "gloves & PPE"),
237
+ ("Electrical Safety", "Grounding of machines, surge protectors, ELCBs, "
238
+ "visible warning signs"),
239
+ ("Public Health", "Regular cleaning, pest-proofing, first aid kit on site"),
240
+ ("Noise Control", "Machines installed with dampeners; zoning of quiet vs. "
241
+ "loud areas"),
242
+ ]
243
+ SECURITY_FEATURES = [
244
+ "Fenced Compound (block wall or wire mesh)",
245
+ "Night Security Personnel (optional)",
246
+ "Solar-powered floodlights",
247
+ "Inventory Ledger for Inputs and Outputs",
248
+ ]
249
+ FIGURE_AGROHUB = "Agrohub Station"
250
+
251
+ # --- Financial Model: the implementation-model catalogue (Table 43) -------- #
252
+ IMPLEMENTATION_MODELS_FULL = [
253
+ ("Individual Model - New Equipment",
254
+ "Under this model, beneficiaries lease newly procured equipment "
255
+ "individually on a lease-to-own basis. Each beneficiary is solely "
256
+ "responsible for repayment and eventual ownership of the equipment."),
257
+ ("Individual Model - Retrofit",
258
+ "Existing beneficiary equipment powered by fossil fuels is retrofitted to "
259
+ "electric-powered systems. Beneficiaries pay for the upgrade in "
260
+ "installments over the agreed loan repayment period."),
261
+ ("Agrohub Model",
262
+ "An agricultural hub is established within the community, equipped with "
263
+ "shared agro-processing machinery. Beneficiaries collectively lease and "
264
+ "manage the equipment as a cooperative, operating under a lease-to-own "
265
+ "arrangement."),
266
+ ("Hybrid Model - New Equipment",
267
+ "A shared agricultural hub used by two beneficiary sectors: Farmer "
268
+ "Cooperatives lease new Agrohub equipment collectively on a lease-to-own "
269
+ "basis; Agroprocessors lease new equipment individually on a lease-to-own "
270
+ "basis."),
271
+ ("Hybrid Model - New Equipment + Retrofit",
272
+ "As the Hybrid Model, but agroprocessors have existing fossil-fuel "
273
+ "equipment retrofitted to electric and pay for the upgrade over the loan "
274
+ "period."),
275
+ ("Agrohub Model + Grant",
276
+ "As the Agrohub Model, but a grant covers the cost of constructing the "
277
+ "Agrohub structure, reducing the burden on beneficiaries."),
278
+ ("Hybrid Model - New Equipment + Grant",
279
+ "As the Hybrid Model (New Equipment), but a grant covers the Agrohub "
280
+ "structure while beneficiaries keep lease-to-own payments for equipment."),
281
+ ("Hybrid Model - New Equipment + Retrofit + Grant",
282
+ "As the Hybrid Model (New Equipment + Retrofit), with a grant covering the "
283
+ "Agrohub structure for both cooperatives and individual agroprocessors."),
284
+ ]
285
+
286
+ IMPLEMENTATION_PLAN = [ # Table 48 — S/N, Title, Responsibility, Duration, Timeline
287
+ ("1", "SPV selection", "Procurement, PUE team", "2 weeks", "Week 1 - 2"),
288
+ ("2", "Contract Signing", "Legal and PUE team", "2 weeks", "Week 3 - 4"),
289
+ ("3", "Land Engagement with Community",
290
+ "SPV, Minigrid Developer and PUE team", "2 weeks", "Week 4 - 5"),
291
+ ("4", "Project Design",
292
+ "Minigrid developer, SPV, OEM, IOT, Civil engineer and PUE team",
293
+ "2 weeks", "Week 6 - 7"),
294
+ ("5", "Submission of Budgets", "SPV, procurement and PUE team", "1 week",
295
+ "Week 8"),
296
+ ("6", "First Payment Made", "SPV, Procurement team, PUE team", "2 weeks",
297
+ "Week 9 - 10"),
298
+ ("7", "Account Setup", "SPV, Funder firm", "2 weeks", "Week 11 - 12"),
299
+ ("8", "Mobilization", "SPV and PUE team", "6 weeks", "Week 13 - 19"),
300
+ ("9", "Transportation, Installation and Commissioning",
301
+ "SPV and PUE team", "2 weeks", "Week 20 - 21"),
302
+ ("10", "Monitoring and Evaluation",
303
+ "SPV, Financial team and PUE team", "Every Quarter", "Week 25 onward"),
304
+ ]
305
+
306
+ IMPLEMENTATION_PLAN_INTRO = (
307
+ "The implementation for the {community} PUE initiative will take a minimum "
308
+ "of 21 weeks (5 months and 1 week) from SPV selection to PUE installation, "
309
+ "testing and commissioning. The PUE implementation for the individual "
310
+ "deployment model will follow the process below.")
311
+
312
+ # --- Conditions for Scaleup (verbatim boilerplate, structured) -------------- #
313
+ SCALEUP_INTRO = ("Below are the key metrics (KPIs) the community must attain "
314
+ "before we \u201cactivate\u201d the move from Pilot to Scale-up.")
315
+
316
+ SCALEUP_SECTIONS = [
317
+ ("Financial & Repayment Metrics (Lease-to-Own triggers)", [
318
+ ("Portfolio On-Time Repayment Rate (core trigger)", [
319
+ "Target: \u2265 95% on-time repayment for 3 consecutive months",
320
+ "Definition: % of expected monthly repayments received by due date",
321
+ "Why it matters: proves discipline + bankability"]),
322
+ ("Portfolio at Risk (PAR) \u2013 early delinquency control", [
323
+ "PAR 30 (payments 30+ days late): Target \u2264 5%",
324
+ "PAR 60: Target \u2264 2%",
325
+ "Why it matters: if PAR rises during pilot, scaling multiplies "
326
+ "defaults."]),
327
+ ("Collections Coverage of Operating Costs (hub sustainability)", [
328
+ "Target: Collections cover 100% of operators\u2019 wages, routine "
329
+ "maintenance/spares, insurance allocations, admin/collections costs",
330
+ "Threshold: OPEX Coverage Ratio \u2265 1.0 for 3 straight months"]),
331
+ ("Minimum Monthly Collections Threshold", [
332
+ "Achieve \u2265 90% of expected monthly inflow for 3 consecutive "
333
+ "months (thresholds derived from the Monthly Revenue tables above)."]),
334
+ ("Reserve Fund Build", [
335
+ "Maintain a Maintenance & Replacement Reserve \u2265 2 months of "
336
+ "routine O&M cost OR \u2265 5% of monthly collections, whichever is "
337
+ "higher",
338
+ "Evidence: separate wallet/account + monthly statement"]),
339
+ ]),
340
+ ("Operational & Technical Metrics (Proof the hub can run)", [
341
+ ("Asset Uptime (processing + charging + SME loads)", [
342
+ "Target: \u2265 90\u201395% uptime during planned operational hours",
343
+ "Evidence: IoT runtime logs + operator downtime register"]),
344
+ ("Energy System Reliability", [
345
+ "Target: \u2265 95% availability of the modular solar system",
346
+ "Evidence: inverter logs, battery cycles, fault logs"]),
347
+ ("Utilization Threshold (demand proof)", [
348
+ "Processing utilization \u2265 60% of available hours for 8 "
349
+ "consecutive weeks, or a waiting list \u2265 20% of slots weekly"]),
350
+ ("Maintenance Compliance (culture trigger)", [
351
+ "Target: 100% daily checks + \u2265 90% weekly preventive "
352
+ "maintenance tasks completed",
353
+ "Evidence: signed checklists + monthly maintenance audit"]),
354
+ ]),
355
+ ("Governance & Accountability Metrics", [
356
+ ("Operating Charter Compliance", [
357
+ "Target: zero \u201coff-book\u201d transactions for 90 days",
358
+ "Evidence: receipts, booking system, reconciliation reports"]),
359
+ ("Dispute Resolution Performance", [
360
+ "Target: \u2265 90% of grievances resolved within 7 days",
361
+ "Evidence: grievance log with timestamps + outcomes"]),
362
+ ("Governance Discipline", [
363
+ "Hub Management Committee meets monthly with minutes, financial "
364
+ "summary review, and downtime/maintenance report review",
365
+ "Evidence: signed minutes + action tracker"]),
366
+ ]),
367
+ ("Data & IoT Metrics", [
368
+ ("Data Completeness", [
369
+ "Target: \u2265 90% completeness across runtime, energy (kWh), "
370
+ "booking/throughput records, payments/receipts",
371
+ "Evidence: monthly dashboard export + spot-checks"]),
372
+ ("Data Integrity (anti-fraud)", [
373
+ "Variance between recorded throughput vs runtime vs energy draw "
374
+ "stays within an agreed tolerance (e.g. \u00b110\u201315%)",
375
+ "Evidence: audit comparisons"]),
376
+ ]),
377
+ ("Market & Value Chain Metrics", [
378
+ ("Market Pull / Offtake Proof", [
379
+ "At least one of: signed buyer agreement / offtake MoU; repeat bulk "
380
+ "buyers with documented purchase history; 3-month processor sales "
381
+ "growth trend"]),
382
+ ("Quality Standard Compliance", [
383
+ "Consistent quality specs (quality, hygiene, packaging) with "
384
+ "\u2264 5% rejection/complaints",
385
+ "Evidence: buyer feedback + QC log"]),
386
+ ]),
387
+ ("Inclusion & Social Metrics", [
388
+ ("Participation Targets (minimum inclusion triggers)", [
389
+ "\u2265 25% of hub processing slots allocated to women/youth "
390
+ "beneficiaries, or a clear step-up plan toward that",
391
+ "At least 1 female in a financial-control role (cashier/admin/"
392
+ "treasurer) or deputy role",
393
+ "Evidence: booking records + committee roster"]),
394
+ ]),
395
+ ]
396
+
397
+ SCALEUP_GATE = [
398
+ "Repayment performance: On-time repayment \u226595% for 3 months; "
399
+ "PAR30 \u22645%",
400
+ "Collections threshold: \u226590% of expected monthly inflow for 3 months",
401
+ "Operational reliability: uptime \u226590\u201395% and energy availability "
402
+ "\u226595%",
403
+ "Demand proof: utilization \u226560% for 8 weeks or waiting list \u226520% "
404
+ "of slots weekly",
405
+ "Governance proof: zero off-book transactions + grievances resolved within "
406
+ "7 days",
407
+ "Data proof: \u226590% completeness and auditable logs",
408
+ ]
409
+
410
+ MER_INTRO = (
411
+ "To ensure the effectiveness, efficiency, and sustainability of the PUE "
412
+ "intervention in {community}, a structured Monitoring, Evaluation, and "
413
+ "Reporting (MER) framework will be deployed. This system will track energy "
414
+ "usage, equipment performance, productivity outcomes, and community impact, "
415
+ "while integrating mechanisms for continuous feedback and improvement.")
416
+
417
+ DATA_TOOLS = [ # Table — Tool / Purpose / Managed By
418
+ ("IoT Smart Meters", "Track real-time energy consumption & machine uptime",
419
+ "Technical Partner / SPV"),
420
+ ("Mobile Enumerator Forms", "Collect monthly performance and business data",
421
+ "PUE team"),
422
+ ("WhatsApp / SMS Survey Tools", "Collect qualitative feedback from "
423
+ "end-users", "Various stakeholders"),
424
+ ("Equipment Logbooks", "Record hours of use and faults", "SPV"),
425
+ ("Digital Dashboard (central)", "Aggregates all KPIs and generates "
426
+ "automated reports", "DI Team"),
427
+ ]
428
+
429
+ FEEDBACK_LOOPS = [ # Table — S/N, Item, Who, Frequency, Purpose
430
+ ("1", "Operator Feedback Sessions", "Operator and cooperative member",
431
+ "Monthly", "Discuss challenges, machine performance, and suggestions"),
432
+ ("2", "Rider Survey Check-ins", "E-mobility operators and association",
433
+ "Bi-Monthly", "Assess performance, route efficiency, battery swap "
434
+ "satisfaction, and fuel savings"),
435
+ ("3", "Performance Alerts", "All IoT enabled devices", "Instant",
436
+ "Flag high downtime, abnormal consumption, or device malfunctions"),
437
+ ("4", "Grievance Redress System", "All beneficiaries", "Instant",
438
+ "Mobile/WhatsApp hotline for complaints or service requests in real time"),
439
+ ("5", "Quarterly MEL Reports", "Shared with stakeholders", "Quarterly",
440
+ "Key insights, deviations, and corrective actions"),
441
+ ]
442
+
443
+ PROJECTED_IMPACT_DEFAULT = [ # Table 54 — used if the sheet has none
444
+ ("Processing Output", "Tons/bags processed monthly", "+250\u2013350% increase"),
445
+ ("Average Processor Income", "\u20a6 / month", "+100\u2013200% growth"),
446
+ ("E-Mobility Operators", "Monthly Savings", "\u20a640,000\u2013\u20a6100,000"),
447
+ ("Mini-grid Utilization", "kWh / day increase", "+400\u2013500%"),
448
+ ("CO\u2082 Emission Reduction", "Estimated % decrease", "25\u201340%"),
449
+ ("Employment", "Jobs created", "120+"),
450
+ ("Women Beneficiaries", "% of total beneficiaries", "Over 45%"),
451
+ ]
452
+
453
+ KEY_CHALLENGES = [ # Table 56 — Lesson Learned / Recommended Best Practice
454
+ ("Inexperienced enumerators from the database network",
455
+ "Develop a tiered enumerator training program (digital LMS + field "
456
+ "orientation); add supervisory review layers; run competency tests before "
457
+ "deployment; maintain a certified \u201cgold pool\u201d for sensitive PUE "
458
+ "projects."),
459
+ ("Inconsistent data sources",
460
+ "Establish a standardized data-collection template; triangulate each data "
461
+ "point against \u22652 sources; use a validation checklist; centralize "
462
+ "data in a version-controlled repository."),
463
+ ("Limited literacy and data recording among processors",
464
+ "Simplify questionnaires with visual aids; use assisted interviews; "
465
+ "provide basic recordkeeping training; pilot digital record tools."),
466
+ ("Delayed responses from OEMs",
467
+ "Prequalify a roster of responsive OEMs with SLAs; use framework "
468
+ "agreements; assign a vendor liaison; tie penalties/incentives to "
469
+ "timeliness."),
470
+ ("Partial supplier responses",
471
+ "Issue structured RFQ templates; reject incomplete submissions; establish "
472
+ "preferred-supplier partnerships; require after-sales commitments."),
473
+ ("Data accuracy and completeness",
474
+ "Deploy real-time tools with GPS + timestamp; automate validation/outlier "
475
+ "checks; require double-entry verification; conduct spot checks."),
476
+ ("Unrealistic delivery timeline",
477
+ "Create a standard delivery timeline; build 20\u201330% buffer periods; "
478
+ "confirm realistic OEM lead times early; use Gantt planning."),
479
+ ]
480
+
481
+ FORECASTED_CHALLENGES = [ # Table 57 — Lesson Learned / Recommended Best Practice
482
+ ("Overloading of electric motors on single-phase supply above 2\u20133 HP",
483
+ "Use three-phase power systems for all induction motors"),
484
+ ("Mini-grid disruption from startup surge currents",
485
+ "Equip all induction-motor machines with variable speed drives or soft "
486
+ "starters"),
487
+ ("Reliability of mini-grid power supply",
488
+ "Schedule PUE operations during peak solar generation hours"),
489
+ ("Insufficient mini-grid capacity for additional agro-processors",
490
+ "Coordinate with developers to confirm grid compatibility and capacity "
491
+ "before deployment"),
492
+ ("Inconsistent year-round equipment use",
493
+ "Introduce smaller PUE appliances (wet milling, cold storage) to stabilise "
494
+ "off-peak demand"),
495
+ ("Need to extend the mini-grid network to reach all processors",
496
+ "Reassess grid design with developers to incorporate PUE integration"),
497
+ ("Risk of equipment theft",
498
+ "Allocate security resources, especially for centralised agro hubs"),
499
+ ]
pue_report_agent/validate.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ validate.py
3
+ ===========
4
+ Post-extraction sanity checks. These exist because the source spreadsheets are
5
+ hand-maintained and carry known defects (stale template text, totals that don't
6
+ foot, interview counts that disagree between tabs). The agent should surface
7
+ these to a human reviewer rather than silently emitting a wrong report — that
8
+ is exactly how the Kwarua Tasha report ended up internally inconsistent.
9
+
10
+ Each check appends a human-readable string to `data.warnings`. Nothing here
11
+ mutates the numbers; correction is a human decision.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .schema import PUEReportData
17
+
18
+
19
+ def validate(data: PUEReportData) -> PUEReportData:
20
+ _check_interview_totals(data)
21
+ _check_sme_consistency(data)
22
+ _check_budget_foots(data)
23
+ _check_gender_baseline(data)
24
+ _check_capacities_present(data)
25
+ _check_crop_percentages(data)
26
+ _check_co2_inputs(data)
27
+ return data
28
+
29
+
30
+ def _check_interview_totals(d: PUEReportData) -> None:
31
+ parts = (d.interviews.farmers + d.interviews.agroprocessors +
32
+ d.interviews.sme + d.interviews.mobility)
33
+ if d.interviews.total and parts != d.interviews.total:
34
+ d.warnings.append(
35
+ f"Interview total ({d.interviews.total}) != sum of categories "
36
+ f"({parts}). Reconcile the 'People interviewed' block.")
37
+
38
+
39
+ def _check_sme_consistency(d: PUEReportData) -> None:
40
+ listed = sum(t.count for t in d.sme.types)
41
+ if d.sme.total and listed and listed != d.sme.total:
42
+ d.warnings.append(
43
+ f"SME header total ({d.sme.total}) != sum of SME type counts "
44
+ f"({listed}). The Kwarua Tasha report had this exact mismatch.")
45
+ if d.interviews.sme and d.sme.total and d.interviews.sme != d.sme.total:
46
+ d.warnings.append(
47
+ f"SME interview count ({d.interviews.sme}) != SME breakdown total "
48
+ f"({d.sme.total}).")
49
+
50
+
51
+ def _check_budget_foots(d: PUEReportData) -> None:
52
+ for s in d.budget.sections:
53
+ if s.items and s.total is not None:
54
+ line_sum = sum(i.total_price or 0 for i in s.items)
55
+ if line_sum and abs(line_sum - s.total) > 1:
56
+ d.warnings.append(
57
+ f"Budget section '{s.name}': line items sum to "
58
+ f"{line_sum:,.0f} but stated total is {s.total:,.0f}.")
59
+
60
+
61
+ def _check_gender_baseline(d: PUEReportData) -> None:
62
+ # The boilerplate 'Inclusion' section hardcodes '34 male vs 7 female';
63
+ # confirm the real farmer baseline so the generator can substitute it.
64
+ if d.farmers.male or d.farmers.female:
65
+ d.warnings.append(
66
+ f"Farmer gender baseline is {d.farmers.male} male / "
67
+ f"{d.farmers.female} female. Ensure the Inclusion section uses "
68
+ f"these figures, not the templated '34 male vs 7 female'.")
69
+
70
+
71
+ def _check_capacities_present(d: PUEReportData) -> None:
72
+ if not d.minigrid.solar_pv:
73
+ d.warnings.append(
74
+ "Mini-grid capacity (Solar PV / Battery / Annual Consumption) was "
75
+ "not supplied. These are developer figures and must be provided "
76
+ "as an external input; they are not derivable from the survey data.")
77
+
78
+
79
+ def _check_co2_inputs(d: PUEReportData) -> None:
80
+ rows = d.processors.co2_assessment
81
+ if rows and all(r.annual_fuel_litres is None for r in rows):
82
+ machines = ", ".join(sorted({r.machine for r in rows}))
83
+ d.warnings.append(
84
+ f"CO2 assessment: fuel-powered machines detected ({machines}) but "
85
+ f"per-machine annual fuel consumption (litres) is not in the sheet, "
86
+ f"so emissions can't be computed. Supply annual fuel litres to "
87
+ f"complete the CO2 table (IPCC factors 2.31 petrol / 2.86 diesel).")
88
+
89
+
90
+ def _check_crop_percentages(d: PUEReportData) -> None:
91
+ # Percentages are share-of-households and legitimately exceed 100% in total
92
+ # (a farmer grows multiple crops), so we only flag individual outliers.
93
+ for c in d.farmers.crops:
94
+ if c.percentage > 100:
95
+ d.warnings.append(
96
+ f"Crop '{c.crop}' has household share {c.percentage}% > 100%; "
97
+ f"check the farming-household count.")
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core extraction + validation (no API key required)
2
+ openpyxl>=3.1
3
+ pydantic>=2.5
4
+
5
+ # Web app (Hugging Face Space front-end)
6
+ streamlit>=1.40
7
+
8
+ # LLM narrative generation (agent.py) — currently all narratives use Gemini
9
+ langchain>=0.2
10
+ langchain-core>=0.2
11
+ langchain-google-genai>=1.0
12
+ # langchain-anthropic>=0.1 # uncomment if you switch any chain back to Anthropic
13
+
14
+ # Optional, for render.py (deterministic .docx output)
15
+ python-docx>=1.1
run.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ run.py
3
+ ======
4
+ CLI entry point. Demonstrates the full pipeline and prints a QA summary.
5
+
6
+ Usage:
7
+ # Extraction + validation only (no API key needed):
8
+ python run.py path/to/Master_Sheet.xlsx --no-llm
9
+
10
+ # Full run with narratives (requires ANTHROPIC_API_KEY):
11
+ python run.py path/to/Master_Sheet.xlsx --developer "Green Edge Consortium"
12
+
13
+ The mini-grid capacities are an external input (developer-supplied, not in the
14
+ survey data). Pass them with --solar-pv / --battery / --annual-consumption, or
15
+ fill them in afterwards on the returned object before rendering.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+
22
+ from pue_report_agent import extract_master_sheet, validate, recompute
23
+ from pue_report_agent.schema import EnergySystemSpec
24
+
25
+
26
+ def main() -> None:
27
+ ap = argparse.ArgumentParser(description="PUE report builder")
28
+ ap.add_argument("master_sheet")
29
+ ap.add_argument("--developer", default="the mini-grid developer")
30
+ ap.add_argument("--no-llm", action="store_true",
31
+ help="skip narrative generation (no API key required)")
32
+ ap.add_argument("--solar-pv", default="")
33
+ ap.add_argument("--battery", default="")
34
+ ap.add_argument("--annual-consumption", default="")
35
+ ap.add_argument("--date", default="", help="report date for the title page")
36
+ ap.add_argument("--docx-out", default="",
37
+ help="render a template-faithful .docx to this path")
38
+ ap.add_argument("--json-out", default="",
39
+ help="write the full PUEReportData JSON to this path")
40
+ args = ap.parse_args()
41
+
42
+ # 1. Deterministic extraction
43
+ data = extract_master_sheet(args.master_sheet)
44
+
45
+ # 2. Recompute every total from line items (never trust a sheet total)
46
+ data = recompute(data)
47
+
48
+ # 3. External developer-supplied capacity overrides (optional). Merge into
49
+ # the already-extracted minigrid so we don't discard the distribution
50
+ # metrics / inverter that extraction pulled from Key Findings.
51
+ if args.solar_pv:
52
+ data.minigrid.solar_pv = args.solar_pv
53
+ if args.battery:
54
+ data.minigrid.battery_storage = args.battery
55
+ if args.annual_consumption:
56
+ data.minigrid.annual_consumption = args.annual_consumption
57
+
58
+ # 4. Validation / reconciliation
59
+ data = validate(data)
60
+
61
+ # 5. Narratives (optional). All narratives use Gemini.
62
+ if not args.no_llm:
63
+ from pue_report_agent import generate_narratives
64
+ if generate_narratives is None:
65
+ print("[warn] langchain not installed; skipping narratives.")
66
+ else:
67
+ data = generate_narratives(data)
68
+
69
+ # 6. QA summary
70
+ _print_summary(data)
71
+
72
+ # 7. Render the template-faithful Word document
73
+ if args.docx_out:
74
+ from pue_report_agent.render import render
75
+ render(data, args.docx_out, developer=args.developer,
76
+ report_date=args.date)
77
+ print(f"\nWrote {args.docx_out}")
78
+
79
+ if args.json_out:
80
+ with open(args.json_out, "w") as fh:
81
+ fh.write(data.model_dump_json(indent=2))
82
+ print(f"Wrote {args.json_out}")
83
+
84
+
85
+ def _print_summary(data) -> None:
86
+ c = data.community
87
+ print("=" * 64)
88
+ print(f" {c.name} ({c.lga}, {c.state})")
89
+ print("=" * 64)
90
+ print(f" Interviewed: {data.interviews.total} "
91
+ f"(farmers {data.interviews.farmers}, processors "
92
+ f"{data.interviews.agroprocessors}, SME {data.interviews.sme}, "
93
+ f"mobility {data.interviews.mobility})")
94
+ print(f" Farmers: {data.farmers.male}M / {data.farmers.female}F, "
95
+ f"{len(data.farmers.crops)} crops")
96
+ print(f" Processors: {data.processors.male}M / {data.processors.female}F, "
97
+ f"{len(data.processors.machinery)} machines on record")
98
+ print(f" SME types: {len(data.sme.types)} Mobility: {data.mobility.total}")
99
+ print(f" Pilot energy: {data.equipment_pilot.projection_total_kwh} kWh "
100
+ f"ScaleUp: {data.equipment_scaleup.projection_total_kwh} kWh")
101
+ print(f" Budget grand total: ₦{(data.budget.grand_total or 0):,.0f}")
102
+ print(f" Financial models: {[m.model_name for m in data.financial_models]}")
103
+ print()
104
+ if data.warnings:
105
+ print(f" ⚠ {len(data.warnings)} item(s) need review:")
106
+ for w in data.warnings:
107
+ print(f" • {w}")
108
+ else:
109
+ print(" ✓ no warnings")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()