Spaces:
Sleeping
Sleeping
Upload 15 files
Browse files- .gitignore +5 -0
- DEVELOPER_NOTES.md +155 -0
- README.md +49 -0
- app.py +146 -0
- pue_report_agent/__init__.py +34 -0
- pue_report_agent/agent.py +184 -0
- pue_report_agent/calc.py +168 -0
- pue_report_agent/extractors.py +1037 -0
- pue_report_agent/fallback_narrative.py +123 -0
- pue_report_agent/render.py +911 -0
- pue_report_agent/schema.py +336 -0
- pue_report_agent/templates.py +499 -0
- pue_report_agent/validate.py +97 -0
- requirements.txt +15 -0
- run.py +113 -0
.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()
|