disaster-fund-calculation-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- disaster_fund_calculation_engine-0.1.0.dist-info/METADATA +296 -0
- disaster_fund_calculation_engine-0.1.0.dist-info/RECORD +11 -0
- disaster_fund_calculation_engine-0.1.0.dist-info/WHEEL +5 -0
- disaster_fund_calculation_engine-0.1.0.dist-info/top_level.txt +1 -0
- fm_calculation_engine/__init__.py +51 -0
- fm_calculation_engine/beneficiary.py +76 -0
- fm_calculation_engine/common.py +184 -0
- fm_calculation_engine/core.py +183 -0
- fm_calculation_engine/district.py +119 -0
- fm_calculation_engine/government.py +303 -0
- fm_calculation_engine/py.typed +0 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: disaster-fund-calculation-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Disaster Reconstruction & Relief Fund Calculation Engine: Multi-Tranche Liabilities, Donation Offsets, and District Pooling
|
|
5
|
+
Author-email: Pujan Pandey <arghakhanchipujan@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/arghakhanchipujan/Fund-Management
|
|
8
|
+
Keywords: disaster-relief,fund-management,reconstruction,nepal,grants,calculation-engine,tranches
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
18
|
+
Classifier: Development Status :: 3 - Alpha
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: disaster-fund-plan>=0.1.0
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
|
|
24
|
+
# disaster-fund-calculation-engine
|
|
25
|
+
|
|
26
|
+
**Turn a validated disaster plan plus beneficiary counts into exact rupee figures: what a household receives, what each government owes, and what each district collects and forwards.**
|
|
27
|
+
|
|
28
|
+
It is the calculation half of the system. The plan itself (structure, grant rates, liability split) comes from [`disaster-fund-plan`](https://pypi.org/project/disaster-fund-plan/), which is installed automatically.
|
|
29
|
+
|
|
30
|
+
- Python 3.10 or newer
|
|
31
|
+
- Dependencies: `disaster-fund-plan`, `pydantic>=2`
|
|
32
|
+
- Import name: `fm_calculation_engine` (the PyPI name is `disaster-fund-calculation-engine`)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install disaster-fund-calculation-engine
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## The three outputs at a glance
|
|
41
|
+
|
|
42
|
+
| Question you are asking | Function | Who asks it |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| *"What does one household receive, tranche by tranche?"* | `receivable_amounts(plan, profile)` | Caseworker, beneficiary-facing app |
|
|
45
|
+
| *"How much does the Federal / a Province / a Local government owe, after donations?"* | `calculate_government_liability(plan, profile, counts)` | Finance office of that government |
|
|
46
|
+
| *"How much must a district collect from each level, and how much does it forward to each local government?"* | `calculate_district_fund(plan, profile, counts)` | District coordination office |
|
|
47
|
+
|
|
48
|
+
All three read the **same** internal schedule, so they can never disagree with each other (see [Guarantees](#guarantees)).
|
|
49
|
+
|
|
50
|
+
## Inputs
|
|
51
|
+
|
|
52
|
+
**1. The plan**: loaded with `disaster-fund-plan`:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fm_plan import load_plan
|
|
56
|
+
plan = load_plan("example_plan.json")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**2. Beneficiary counts** (outputs 2 and 3): how many households fall in each category. A list of dictionaries:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
counts = [
|
|
63
|
+
{"district": "Gorkha", "local_level": "Gorkha Municipality",
|
|
64
|
+
"disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 10},
|
|
65
|
+
{"district": "Sindhupalchok", "local_level": "Melamchi Municipality",
|
|
66
|
+
"disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 20},
|
|
67
|
+
{"district": "Sindhupalchok", "local_level": "Helambu Rural Municipality",
|
|
68
|
+
"disaster_type": "Earthquake", "solution_type": "Retrofitting", "count": 5},
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Field | Required | Notes |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `district` | yes | Name **or code** (`"Sindhupalchok"` or `"23"`) |
|
|
75
|
+
| `disaster_type`, `solution_type` | yes | Must exist in the plan; capital letters do not matter |
|
|
76
|
+
| `count` | yes | Number of households, `0` or more |
|
|
77
|
+
| `local_level` | no | Name or code. If omitted, see [use case 6](#6-counts-without-a-local-level) |
|
|
78
|
+
| `region` | no | Taken from the plan's district. If you supply it, it must match the plan |
|
|
79
|
+
| `district_code`, `local_level_code` | no | Optional cross-check (non-empty strings). If given, they must match the plan's code for that district / local level, otherwise the row is rejected (a local-level code needs a `local_level`) |
|
|
80
|
+
|
|
81
|
+
**3. A profile** for the question you are asking (shown in each use case below). Every profile carries the `plan_id`, which must match the loaded plan: this stops results being calculated against the wrong plan version.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Use cases
|
|
86
|
+
|
|
87
|
+
### 1. What does one household receive?
|
|
88
|
+
|
|
89
|
+
Given a household's region, disaster type and solution type, get its tranche schedule.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from fm_calculation_engine import receivable_amounts
|
|
93
|
+
|
|
94
|
+
out = receivable_amounts(plan, {
|
|
95
|
+
"plan_id": plan.plan_id,
|
|
96
|
+
"region": "Pahad",
|
|
97
|
+
"disaster_type": "Earthquake",
|
|
98
|
+
"solution_type": "Reconstruction",
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
print(out.total) # 300000
|
|
102
|
+
for t in out.tranches:
|
|
103
|
+
print(t.tranche, t.stage, t.amount)
|
|
104
|
+
# 1 shelter 15000
|
|
105
|
+
# 2 shelter 15000
|
|
106
|
+
# 3 main 100000
|
|
107
|
+
# 4 main 100000
|
|
108
|
+
# 5 main 70000
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### 2. What does a government owe? (Federal, Province or Local)
|
|
112
|
+
|
|
113
|
+
The same function works for every level. The engine works out which level you mean from the name or code.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from fm_calculation_engine import calculate_government_liability
|
|
117
|
+
|
|
118
|
+
fed = calculate_government_liability(
|
|
119
|
+
plan,
|
|
120
|
+
{"plan_id": plan.plan_id, "gov_entity_name": "Government of Nepal"}, # or "FED"
|
|
121
|
+
counts,
|
|
122
|
+
)
|
|
123
|
+
print(fed.gov_type, fed.liability_percentage) # federal 50.0
|
|
124
|
+
print(fed.total_gross_liability) # 4750000
|
|
125
|
+
print(fed.total_net_payable) # 4750000 (no donations given)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Other levels, using the plan from the example file:
|
|
129
|
+
|
|
130
|
+
| `gov_entity_name` | Resolves to | Pays |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| `"Government of Nepal"` or `"FED"` | federal | 50% of every district's cost |
|
|
133
|
+
| `"Gandaki Province"` or `"P4"` | province | 30%, **only** for districts inside that province (Gorkha) |
|
|
134
|
+
| `"Melamchi Municipality"` or `"23001"` | local | 20% of its district's cost, split between the district's local levels in proportion to what each needs |
|
|
135
|
+
|
|
136
|
+
Each result has a per-tranche, per-district breakdown (`fed.tranches[i].districts`) and a summary of who the beneficiaries are (`fed.beneficiary_summary`).
|
|
137
|
+
|
|
138
|
+
### 3. Apply donations, with automatic rollover
|
|
139
|
+
|
|
140
|
+
INGO, NGO and other donors give money to a government. It reduces that government's **cash outflow**, starting at tranche 1; whatever is not used rolls over to the next tranche.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
fed = calculate_government_liability(
|
|
144
|
+
plan,
|
|
145
|
+
{
|
|
146
|
+
"plan_id": plan.plan_id,
|
|
147
|
+
"gov_entity_name": "Government of Nepal",
|
|
148
|
+
"donations": [
|
|
149
|
+
{"donor_name": "Red Cross", "amount": 250000, "received_date": "2015-10-01"},
|
|
150
|
+
{"donor_name": "UNICEF", "amount": 150000, "received_date": "2015-10-05"},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
counts,
|
|
154
|
+
)
|
|
155
|
+
for t in fed.tranches[:3]:
|
|
156
|
+
print(t.tranche, t.gross_liability, t.donation_applied, t.net_payable, t.remaining_donation)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
tranche gross donation applied net payable donation left
|
|
161
|
+
1 350,000 350,000 0 50,000 ↠400,000 donated, tranche 1 consumes 350,000
|
|
162
|
+
2 350,000 50,000 300,000 0 ↠the 50,000 left over rolls into tranche 2
|
|
163
|
+
3 1,500,000 0 1,500,000 0 ↠donations are used up
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Totals: Rs. 400,000 received, Rs. 4,750,000 gross, **Rs. 4,350,000 net cash payable**. The relief inside each tranche is shared across districts in proportion to what each district needs (`tranches[i].districts[j].donation_offset`).
|
|
167
|
+
|
|
168
|
+
Notes: `received_date` is optional and only affects the order donations are listed (oldest first). It does not decide *which tranche* a donation can pay for, because the plan has no tranche due dates.
|
|
169
|
+
|
|
170
|
+
### 4. What must a district collect, and whom does it forward to?
|
|
171
|
+
|
|
172
|
+
A district office needs two answers per tranche: how much to **collect** from Federal, Province and Local, and how much to **forward** to each local government.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from fm_calculation_engine import calculate_district_fund
|
|
176
|
+
|
|
177
|
+
d = calculate_district_fund(
|
|
178
|
+
plan,
|
|
179
|
+
{"plan_id": plan.plan_id, "district_name": "Sindhupalchok"}, # or "23"
|
|
180
|
+
counts,
|
|
181
|
+
)
|
|
182
|
+
print(d.province_name, d.total_beneficiaries, d.grand_total) # Bagmati Province 25 6500000
|
|
183
|
+
|
|
184
|
+
t1 = d.tranches[0]
|
|
185
|
+
print(t1.collectible) # federal=275000 province=165000 local=110000 total=550000
|
|
186
|
+
for x in t1.disbursements:
|
|
187
|
+
print(x.local_gov, x.amount)
|
|
188
|
+
# Melamchi Municipality 300000
|
|
189
|
+
# Helambu Rural Municipality 250000
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Tranche 1 here is 550,000 in total: Rs. 275,000 (50%) from Federal, Rs. 165,000 (30%) from the Province and Rs. 110,000 (20%) from Local, which is then forwarded as 300,000 and 250,000 to the two local governments according to their own beneficiaries.
|
|
193
|
+
|
|
194
|
+
### 5. Reconcile everything (audit and sanity check)
|
|
195
|
+
|
|
196
|
+
Because all outputs come from one schedule, they must add up. This is a useful check for auditors and for your own integration tests:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
levels = ["FED", "P3", "P4", "Gorkha Municipality", "Melamchi Municipality", "Helambu Rural Municipality"]
|
|
200
|
+
total_by_government = sum(
|
|
201
|
+
calculate_government_liability(plan, {"plan_id": plan.plan_id, "gov_entity_name": g}, counts).total_gross_liability
|
|
202
|
+
for g in levels
|
|
203
|
+
)
|
|
204
|
+
total_by_district = sum(
|
|
205
|
+
calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": d}, counts).grand_total
|
|
206
|
+
for d in ("Gorkha", "Sindhupalchok")
|
|
207
|
+
)
|
|
208
|
+
assert total_by_government == total_by_district == 9_500_000
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 6. Counts without a local level
|
|
212
|
+
|
|
213
|
+
Sometimes only district-level totals are known. Leave `local_level` out:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": "23"},
|
|
217
|
+
[{"district": "23", "disaster_type": "Earthquake", "solution_type": "Reconstruction", "count": 10}])
|
|
218
|
+
# tranche 1 -> Melamchi Municipality 75000, Helambu Rural Municipality 75000
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The money for such households is shared **equally** between the district's local levels (odd rupees go to the first ones in the plan). For a local government's own report, these households are shown separately as `unassigned_beneficiaries` and are not included in `total_beneficiaries`.
|
|
222
|
+
|
|
223
|
+
### 7. Catch bad input with readable errors
|
|
224
|
+
|
|
225
|
+
Every problem in the input is reported at once, in plain words, as an `EngineInputError`:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from fm_calculation_engine import EngineInputError
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
calculate_district_fund(plan, {"plan_id": plan.plan_id, "district_name": "Gorkha"}, bad_counts)
|
|
232
|
+
except EngineInputError as error:
|
|
233
|
+
print(error)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
3 problem(s) in beneficiary counts:
|
|
238
|
+
- item 0: district 'Kathmandu' is not in plan's districts ['Gorkha', 'Sindhupalchok']
|
|
239
|
+
- item 0: disaster_type 'Flood' is not in plan's disaster types ['Earthquake']
|
|
240
|
+
- item 1: region 'Terai' does not match district 'Gorkha', which is in region 'Pahad' in this plan
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
You can also validate a count list on its own, before calculating, and get it back normalised to the plan's spelling and codes:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from fm_calculation_engine import validate_beneficiary_counts
|
|
247
|
+
clean = validate_beneficiary_counts(plan, counts)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### 8. A local government whose name exists in two districts
|
|
251
|
+
|
|
252
|
+
Nepal has repeated municipality names. If a local government's name appears in more than one district, the engine refuses to guess; say which district you mean:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
calculate_government_liability(plan, {
|
|
256
|
+
"plan_id": plan.plan_id,
|
|
257
|
+
"gov_entity_name": "Some Municipality",
|
|
258
|
+
"district": "Sindhupalchok",
|
|
259
|
+
}, counts)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
(`district` is only accepted for local governments. Using the official code, such as `"23001"`, avoids the ambiguity altogether.)
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Guarantees
|
|
267
|
+
|
|
268
|
+
- **Exact rupees, no drift.** Every split (liability shares, local-level shares, donation offsets) uses the *largest remainder method* on whole rupees: each part gets what it is owed, and leftover rupees go to the parts with the largest fractions. The parts always add up to the total; `round()` and floating point are never used for money.
|
|
269
|
+
- **One schedule per district.** District total = Federal + Province + Local share, and the local share = the sum of the shares given to its local levels, exactly. Outputs 2 and 3 therefore always reconcile.
|
|
270
|
+
- **Deterministic.** The same input always gives the same output (ties are broken by plan order).
|
|
271
|
+
- **Wrong plan is rejected.** A `plan_id` that does not match the loaded plan is an error.
|
|
272
|
+
- **Names or codes everywhere.** Districts, provinces, local levels and the Federal government can all be given by name or official code, in any capitalisation.
|
|
273
|
+
|
|
274
|
+
## API summary
|
|
275
|
+
|
|
276
|
+
| Name | Purpose |
|
|
277
|
+
| --- | --- |
|
|
278
|
+
| `receivable_amounts(plan, profile)` → `ReceivableAmounts` | Output 1: tranche schedule for one household |
|
|
279
|
+
| `calculate_government_liability(plan, profile, counts)` → `GovernmentLiabilityOutput` | Output 2: what a government owes, with donation offsets and rollover |
|
|
280
|
+
| `calculate_district_fund(plan, profile, counts)` → `DistrictOutput` | Output 3: what a district collects and forwards |
|
|
281
|
+
| `validate_beneficiary_counts(plan, counts)` | Check counts against the plan; returns normalised `BeneficiaryCount` objects |
|
|
282
|
+
| `EngineInputError` | Raised for any invalid input; the message lists every problem |
|
|
283
|
+
| `BeneficiaryProfile`, `GovEntityProfile`, `DistrictProfile`, `BeneficiaryCount`, `Donation` | Input models (a plain `dict` with the same keys works too) |
|
|
284
|
+
|
|
285
|
+
Profiles and counts can be passed as dictionaries or as these model objects. Unknown keys are rejected, so a typo is an error rather than being silently ignored.
|
|
286
|
+
|
|
287
|
+
## Limits
|
|
288
|
+
|
|
289
|
+
- Amounts are whole rupees.
|
|
290
|
+
- The Federal/Province/Local split is the same for every tranche and every stage (shelter and main).
|
|
291
|
+
- Donations are pooled per government and used from tranche 1 onward; they are not tied to a particular tranche or district.
|
|
292
|
+
- A tranche round exists up to the longest grant in the plan. A grant with fewer tranches simply has Rs. 0 in the later rounds.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fm_calculation_engine/__init__.py,sha256=ObTeAsx8O9bAublsgOiC2k2MlEf3HMCJ2wVG4KEVYSg,1238
|
|
2
|
+
fm_calculation_engine/beneficiary.py,sha256=Tv1_FQLObfNskuyz1pIeIi5pBqP_pPvBZ4gTgwDS8gg,2585
|
|
3
|
+
fm_calculation_engine/common.py,sha256=ggC6WxXciO7tNteDgRkjPcGAqfgs1_mojJLeKDOdcJ4,7685
|
|
4
|
+
fm_calculation_engine/core.py,sha256=6_VF_R8A82M3gX-tRCsGuB3btMUbBwf0pA9HPn46IBI,6765
|
|
5
|
+
fm_calculation_engine/district.py,sha256=L2_QYtaVYmtdkPwnjW197ab5wFWg1fhFx4Jx5UxUh64,3661
|
|
6
|
+
fm_calculation_engine/government.py,sha256=cJtplOnqSxHVrdkdZNDzDH73ObOJUzirUD2RGN5Jrbo,11323
|
|
7
|
+
fm_calculation_engine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
disaster_fund_calculation_engine-0.1.0.dist-info/METADATA,sha256=RAhgoRC5KWHpbpPshGL3MUaQLwSg0hfdHUG4Upud6Uw,14052
|
|
9
|
+
disaster_fund_calculation_engine-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
disaster_fund_calculation_engine-0.1.0.dist-info/top_level.txt,sha256=y6f0oj8S941rl9Q3zToNEPiu0DaOeE8ZtH4k9PjHt4U,22
|
|
11
|
+
disaster_fund_calculation_engine-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fm_calculation_engine
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from fm_calculation_engine.beneficiary import (
|
|
2
|
+
BeneficiaryProfile,
|
|
3
|
+
ReceivableAmounts,
|
|
4
|
+
TrancheAmount,
|
|
5
|
+
receivable_amounts,
|
|
6
|
+
)
|
|
7
|
+
from fm_calculation_engine.common import (
|
|
8
|
+
BeneficiaryCount,
|
|
9
|
+
Donation,
|
|
10
|
+
EngineInputError,
|
|
11
|
+
validate_beneficiary_counts,
|
|
12
|
+
)
|
|
13
|
+
from fm_calculation_engine.district import (
|
|
14
|
+
DistrictCollectible,
|
|
15
|
+
DistrictOutput,
|
|
16
|
+
DistrictProfile,
|
|
17
|
+
DistrictTrancheOutput,
|
|
18
|
+
LocalGovDisbursement,
|
|
19
|
+
calculate_district_fund,
|
|
20
|
+
)
|
|
21
|
+
from fm_calculation_engine.government import (
|
|
22
|
+
BeneficiaryGroupSummary,
|
|
23
|
+
GovDistrictPayment,
|
|
24
|
+
GovEntityProfile,
|
|
25
|
+
GovernmentLiabilityOutput,
|
|
26
|
+
GovTrancheLiability,
|
|
27
|
+
calculate_government_liability,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BeneficiaryProfile",
|
|
32
|
+
"ReceivableAmounts",
|
|
33
|
+
"TrancheAmount",
|
|
34
|
+
"receivable_amounts",
|
|
35
|
+
"BeneficiaryCount",
|
|
36
|
+
"Donation",
|
|
37
|
+
"EngineInputError",
|
|
38
|
+
"validate_beneficiary_counts",
|
|
39
|
+
"DistrictProfile",
|
|
40
|
+
"DistrictCollectible",
|
|
41
|
+
"LocalGovDisbursement",
|
|
42
|
+
"DistrictTrancheOutput",
|
|
43
|
+
"DistrictOutput",
|
|
44
|
+
"calculate_district_fund",
|
|
45
|
+
"GovEntityProfile",
|
|
46
|
+
"GovDistrictPayment",
|
|
47
|
+
"GovTrancheLiability",
|
|
48
|
+
"BeneficiaryGroupSummary",
|
|
49
|
+
"GovernmentLiabilityOutput",
|
|
50
|
+
"calculate_government_liability",
|
|
51
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
|
|
2
|
+
|
|
3
|
+
from fm_calculation_engine.common import EngineInputError, match_name, parse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BeneficiaryProfile(StrictModel):
|
|
7
|
+
"""Input: which plan, and which cell of the grant matrix this beneficiary falls in."""
|
|
8
|
+
|
|
9
|
+
plan_id: NonEmptyStr
|
|
10
|
+
region: NonEmptyStr
|
|
11
|
+
disaster_type: NonEmptyStr
|
|
12
|
+
solution_type: NonEmptyStr
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TrancheAmount(StrictModel):
|
|
16
|
+
tranche: int # 1, 2, 3, ...
|
|
17
|
+
stage: str
|
|
18
|
+
amount: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReceivableAmounts(StrictModel):
|
|
22
|
+
"""Output: what the beneficiary receives in each tranche."""
|
|
23
|
+
|
|
24
|
+
plan_id: str
|
|
25
|
+
region: str
|
|
26
|
+
disaster_type: str
|
|
27
|
+
solution_type: str
|
|
28
|
+
tranches: list[TrancheAmount]
|
|
29
|
+
total: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def receivable_amounts(plan: ImplementationPlan, profile) -> ReceivableAmounts:
|
|
33
|
+
"""Receivable amount for each tranche for one beneficiary."""
|
|
34
|
+
p = parse(BeneficiaryProfile, profile)
|
|
35
|
+
|
|
36
|
+
problems = []
|
|
37
|
+
if norm(p.plan_id) != norm(plan.plan_id):
|
|
38
|
+
problems.append(
|
|
39
|
+
f"plan_id '{p.plan_id}' does not match this plan ('{plan.plan_id}')")
|
|
40
|
+
region = match_name(p.region, plan.regions)
|
|
41
|
+
if region is None:
|
|
42
|
+
problems.append(
|
|
43
|
+
f"region '{p.region}' is not in this plan's regions {plan.regions}")
|
|
44
|
+
disaster = match_name(p.disaster_type, plan.disaster_types)
|
|
45
|
+
if disaster is None:
|
|
46
|
+
problems.append(
|
|
47
|
+
f"disaster_type '{p.disaster_type}' is not in this plan's disaster types {plan.disaster_types}"
|
|
48
|
+
)
|
|
49
|
+
solution = match_name(p.solution_type, plan.solution_types)
|
|
50
|
+
if solution is None:
|
|
51
|
+
problems.append(
|
|
52
|
+
f"solution_type '{p.solution_type}' is not in this plan's solution types {plan.solution_types}"
|
|
53
|
+
)
|
|
54
|
+
if problems:
|
|
55
|
+
raise EngineInputError(
|
|
56
|
+
f"{len(problems)} problem(s) in the input:\n" +
|
|
57
|
+
"\n".join(f" - {x}" for x in problems)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
row = next(
|
|
61
|
+
r for r in plan.grant_matrix
|
|
62
|
+
if (norm(r.region), norm(r.disaster_type), norm(r.solution_type))
|
|
63
|
+
== (norm(region), norm(disaster), norm(solution))
|
|
64
|
+
) # a row always exists: the plan guarantees every combination has one
|
|
65
|
+
|
|
66
|
+
return ReceivableAmounts(
|
|
67
|
+
plan_id=plan.plan_id,
|
|
68
|
+
region=region,
|
|
69
|
+
disaster_type=disaster,
|
|
70
|
+
solution_type=solution,
|
|
71
|
+
tranches=[
|
|
72
|
+
TrancheAmount(tranche=number, stage=t.stage, amount=t.amount)
|
|
73
|
+
for number, t in enumerate(row.tranches, start=1)
|
|
74
|
+
],
|
|
75
|
+
total=row.total,
|
|
76
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from pydantic import Field, ValidationError
|
|
5
|
+
|
|
6
|
+
from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EngineInputError(Exception):
|
|
10
|
+
"""The input given to the engine is not valid."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse(model_class, data):
|
|
14
|
+
"""Build model_class from a dictionary (or an existing object).
|
|
15
|
+
|
|
16
|
+
A problem becomes an EngineInputError with one readable line per problem.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
return model_class.model_validate(data)
|
|
20
|
+
except ValidationError as error:
|
|
21
|
+
lines = []
|
|
22
|
+
for err in error.errors():
|
|
23
|
+
where = ".".join(str(part) for part in err["loc"]) or "input"
|
|
24
|
+
message = err["msg"].removeprefix("Value error, ")
|
|
25
|
+
for problem in message.split("; "):
|
|
26
|
+
lines.append(f" - {where}: {problem}")
|
|
27
|
+
raise EngineInputError(
|
|
28
|
+
f"{len(lines)} problem(s) in the input:\n" + "\n".join(lines)
|
|
29
|
+
) from None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Donation(StrictModel):
|
|
33
|
+
"""A donation received by a government entity from INGO, NGO, or private/public source.
|
|
34
|
+
|
|
35
|
+
Donations are pooled: the whole amount is available from tranche 1 and any surplus
|
|
36
|
+
rolls over to the next tranche. `received_date` is recorded for transparency (the
|
|
37
|
+
output lists donations oldest first) but does not decide which tranche a donation
|
|
38
|
+
can pay for, because the plan does not give tranches due dates.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
donor_name: NonEmptyStr
|
|
42
|
+
amount: Annotated[int, Field(
|
|
43
|
+
gt=0, description="Exact donation amount in whole rupees")]
|
|
44
|
+
received_date: date | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BeneficiaryCount(StrictModel):
|
|
48
|
+
"""Beneficiary count for a specific category within a district (and optional local level).
|
|
49
|
+
|
|
50
|
+
`district` and `local_level` accept either official names or codes.
|
|
51
|
+
`region` is optional. A district belongs to exactly one region in the plan, so it
|
|
52
|
+
is taken from the plan. If you do give it, it must match the plan's region for
|
|
53
|
+
that district.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
district: NonEmptyStr
|
|
57
|
+
disaster_type: NonEmptyStr
|
|
58
|
+
solution_type: NonEmptyStr
|
|
59
|
+
count: Annotated[int, Field(
|
|
60
|
+
ge=0, description="Number of beneficiary households")]
|
|
61
|
+
region: NonEmptyStr | None = None
|
|
62
|
+
local_level: NonEmptyStr | None = None
|
|
63
|
+
district_code: NonEmptyStr | None = None
|
|
64
|
+
local_level_code: NonEmptyStr | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def match_name(value: str, allowed: list[str]) -> str | None:
|
|
68
|
+
"""The name in `allowed` that equals `value`, ignoring capital letters, or None."""
|
|
69
|
+
for name in allowed:
|
|
70
|
+
if norm(name) == norm(value):
|
|
71
|
+
return name
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validate_beneficiary_counts(
|
|
76
|
+
plan: ImplementationPlan,
|
|
77
|
+
counts: list[BeneficiaryCount | dict],
|
|
78
|
+
) -> list[BeneficiaryCount]:
|
|
79
|
+
"""Check every count against the plan and return them with the plan's own spelling and codes.
|
|
80
|
+
|
|
81
|
+
Accepts district and local level by name or code.
|
|
82
|
+
A count is rejected when:
|
|
83
|
+
- its district, disaster type or solution type is not in the plan;
|
|
84
|
+
- its region does not match the region the plan gives that district;
|
|
85
|
+
- its local level does not belong to its district;
|
|
86
|
+
- its district (or local level) is not inside the plan's affected areas.
|
|
87
|
+
|
|
88
|
+
In the returned counts `region` is always filled in, taken from the plan.
|
|
89
|
+
"""
|
|
90
|
+
parsed_counts = [parse(BeneficiaryCount, c) for c in counts]
|
|
91
|
+
problems: list[str] = []
|
|
92
|
+
checked: list[BeneficiaryCount] = []
|
|
93
|
+
|
|
94
|
+
for idx, b in enumerate(parsed_counts):
|
|
95
|
+
district = plan.government.find_district(b.district)
|
|
96
|
+
if district is None:
|
|
97
|
+
problems.append(
|
|
98
|
+
f"item {idx}: district '{b.district}' is not in plan's districts "
|
|
99
|
+
f"{[d.name for d in plan.government.districts]}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if b.region is not None:
|
|
103
|
+
matched_region = match_name(b.region, plan.regions)
|
|
104
|
+
if matched_region is None:
|
|
105
|
+
problems.append(
|
|
106
|
+
f"item {idx}: region '{b.region}' is not in plan's regions {plan.regions}"
|
|
107
|
+
)
|
|
108
|
+
elif district is not None and norm(matched_region) != norm(district.region):
|
|
109
|
+
problems.append(
|
|
110
|
+
f"item {idx}: region '{b.region}' does not match district '{district.name}', "
|
|
111
|
+
f"which is in region '{district.region}' in this plan"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
disaster = match_name(b.disaster_type, plan.disaster_types)
|
|
115
|
+
if disaster is None:
|
|
116
|
+
problems.append(
|
|
117
|
+
f"item {idx}: disaster_type '{b.disaster_type}' is not in plan's disaster types "
|
|
118
|
+
f"{plan.disaster_types}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
solution = match_name(b.solution_type, plan.solution_types)
|
|
122
|
+
if solution is None:
|
|
123
|
+
problems.append(
|
|
124
|
+
f"item {idx}: solution_type '{b.solution_type}' is not in plan's solution types "
|
|
125
|
+
f"{plan.solution_types}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
local_level_obj = None
|
|
129
|
+
if b.local_level and district is not None:
|
|
130
|
+
matching_lls = plan.government.find_local_level(b.local_level, district.name)
|
|
131
|
+
if not matching_lls:
|
|
132
|
+
problems.append(
|
|
133
|
+
f"item {idx}: local level '{b.local_level}' does not belong to district "
|
|
134
|
+
f"'{district.name}' in this plan"
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
local_level_obj = matching_lls[0]
|
|
138
|
+
|
|
139
|
+
if district is not None and b.district_code is not None:
|
|
140
|
+
if not district.code or norm(b.district_code) != norm(district.code):
|
|
141
|
+
problems.append(
|
|
142
|
+
f"item {idx}: district_code '{b.district_code}' does not match district "
|
|
143
|
+
f"'{district.name}' (code '{district.code}' in this plan)"
|
|
144
|
+
)
|
|
145
|
+
if b.local_level_code is not None:
|
|
146
|
+
if not b.local_level:
|
|
147
|
+
problems.append(
|
|
148
|
+
f"item {idx}: local_level_code '{b.local_level_code}' was given without a local_level"
|
|
149
|
+
)
|
|
150
|
+
elif local_level_obj is not None and (
|
|
151
|
+
not local_level_obj.code or norm(b.local_level_code) != norm(local_level_obj.code)
|
|
152
|
+
):
|
|
153
|
+
problems.append(
|
|
154
|
+
f"item {idx}: local_level_code '{b.local_level_code}' does not match local level "
|
|
155
|
+
f"'{local_level_obj.name}' (code '{local_level_obj.code}' in this plan)"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
local_level_name = local_level_obj.name if local_level_obj else None
|
|
159
|
+
if district is not None and (local_level_name or not b.local_level):
|
|
160
|
+
if not plan.is_affected(district.name, local_level_name):
|
|
161
|
+
where = f"local level '{b.local_level}' in district '{district.name}'" if b.local_level else f"district '{district.name}'"
|
|
162
|
+
problems.append(
|
|
163
|
+
f"item {idx}: {where} is not in this plan's affected areas")
|
|
164
|
+
|
|
165
|
+
if district is not None and disaster and solution and (local_level_name or not b.local_level):
|
|
166
|
+
checked.append(
|
|
167
|
+
BeneficiaryCount(
|
|
168
|
+
district=district.name,
|
|
169
|
+
district_code=district.code,
|
|
170
|
+
region=district.region,
|
|
171
|
+
disaster_type=disaster,
|
|
172
|
+
solution_type=solution,
|
|
173
|
+
count=b.count,
|
|
174
|
+
local_level=local_level_name,
|
|
175
|
+
local_level_code=local_level_obj.code if local_level_obj else None,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if problems:
|
|
180
|
+
raise EngineInputError(
|
|
181
|
+
f"{len(problems)} problem(s) in beneficiary counts:\n"
|
|
182
|
+
+ "\n".join(f" - {p}" for p in problems)
|
|
183
|
+
)
|
|
184
|
+
return checked
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Shared calculation core.
|
|
2
|
+
|
|
3
|
+
Every number the engine reports about a district comes from ONE schedule built here,
|
|
4
|
+
so the three outputs cannot disagree:
|
|
5
|
+
|
|
6
|
+
district total = federal share + province share + local share (exact)
|
|
7
|
+
local share = sum of the shares given to the district's local levels (exact)
|
|
8
|
+
|
|
9
|
+
Splitting uses the largest remainder method on whole rupees: each part gets the
|
|
10
|
+
whole rupees it is owed, and the few rupees left over go to the parts with the
|
|
11
|
+
largest fractions. The parts therefore always add up to the total, with no
|
|
12
|
+
rounding drift and no dependence on Python's round().
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
|
|
18
|
+
from fm_plan import ImplementationPlan, norm
|
|
19
|
+
|
|
20
|
+
from fm_calculation_engine.common import BeneficiaryCount, EngineInputError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_by_weights(total: int, weights) -> list[int]:
|
|
24
|
+
"""Split a whole-rupee `total` in proportion to `weights`. The parts add up to `total`.
|
|
25
|
+
|
|
26
|
+
Ties between equal fractions go to the earlier item, so the result is always the
|
|
27
|
+
same for the same input.
|
|
28
|
+
"""
|
|
29
|
+
weight_list = [Decimal(str(w)) for w in weights]
|
|
30
|
+
if any(w < 0 for w in weight_list):
|
|
31
|
+
raise ValueError("weights cannot be negative")
|
|
32
|
+
weight_sum = sum(weight_list)
|
|
33
|
+
if total == 0:
|
|
34
|
+
return [0] * len(weight_list)
|
|
35
|
+
if weight_sum == 0:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"cannot split a non-zero amount across weights that add up to zero")
|
|
38
|
+
|
|
39
|
+
quotas = [Decimal(total) * w / weight_sum for w in weight_list]
|
|
40
|
+
# quotas are never negative, so int() is the floor
|
|
41
|
+
parts = [int(q) for q in quotas]
|
|
42
|
+
leftover = total - sum(parts)
|
|
43
|
+
by_largest_fraction = sorted(
|
|
44
|
+
range(len(parts)), key=lambda i: (-(quotas[i] - parts[i]), i)
|
|
45
|
+
)
|
|
46
|
+
for i in by_largest_fraction[:leftover]:
|
|
47
|
+
parts[i] += 1
|
|
48
|
+
return parts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def split_liability(plan: ImplementationPlan, amount: int) -> tuple[int, int, int]:
|
|
52
|
+
"""(federal, province, local) shares of `amount`, adding up to `amount`."""
|
|
53
|
+
g = plan.government_liability
|
|
54
|
+
federal, province, local = split_by_weights(
|
|
55
|
+
amount, [g.federal, g.province, g.local])
|
|
56
|
+
return federal, province, local
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def max_tranche_count(plan: ImplementationPlan) -> int:
|
|
60
|
+
"""Number of tranche rounds: the longest tranche list in the grant matrix."""
|
|
61
|
+
return max((len(r.tranches) for r in plan.grant_matrix), default=0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class TrancheRound:
|
|
66
|
+
"""Everything one district needs for one tranche round."""
|
|
67
|
+
|
|
68
|
+
tranche: int # 1, 2, 3, ...
|
|
69
|
+
stages: list[str]
|
|
70
|
+
# local level -> money it must receive and pay out
|
|
71
|
+
required: dict[str, int]
|
|
72
|
+
total: int # money the district needs this round
|
|
73
|
+
federal: int # federal share of `total`
|
|
74
|
+
province: int # province share of `total`
|
|
75
|
+
local: int # local share of `total`
|
|
76
|
+
local_shares: dict[str, int] # `local` split over the local levels
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class DistrictSchedule:
|
|
81
|
+
name: str
|
|
82
|
+
code: str | None
|
|
83
|
+
province: str
|
|
84
|
+
province_code: str | None
|
|
85
|
+
region: str
|
|
86
|
+
local_levels: list[str]
|
|
87
|
+
local_level_codes: dict[str, str | None]
|
|
88
|
+
rounds: list[TrancheRound]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_district_schedule(
|
|
92
|
+
plan: ImplementationPlan,
|
|
93
|
+
district_name: str,
|
|
94
|
+
district_counts: list[BeneficiaryCount],
|
|
95
|
+
) -> DistrictSchedule:
|
|
96
|
+
"""Work out every tranche round for one district.
|
|
97
|
+
|
|
98
|
+
`district_counts` must already be validated (see validate_beneficiary_counts)
|
|
99
|
+
and belong to this district. `district_name` can be a name or code.
|
|
100
|
+
"""
|
|
101
|
+
district = plan.government.find_district(district_name)
|
|
102
|
+
if district is None:
|
|
103
|
+
raise EngineInputError(
|
|
104
|
+
f"District '{district_name}' was not found in this plan's districts."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
prov_obj = plan.government.find_province(district.province)
|
|
108
|
+
province_name = prov_obj.name if prov_obj else district.province
|
|
109
|
+
province_code = prov_obj.code if prov_obj else None
|
|
110
|
+
|
|
111
|
+
matching_levels = [
|
|
112
|
+
ll for ll in plan.government.local_levels
|
|
113
|
+
if norm(ll.district) == norm(district.code)
|
|
114
|
+
]
|
|
115
|
+
local_levels = [ll.name for ll in matching_levels]
|
|
116
|
+
local_level_codes = {ll.name: ll.code for ll in matching_levels}
|
|
117
|
+
|
|
118
|
+
if district_counts and not local_levels:
|
|
119
|
+
raise EngineInputError(
|
|
120
|
+
f"District '{district.name}' has beneficiaries but no local levels in this plan's "
|
|
121
|
+
"government section, so its money cannot be forwarded. Add its local levels."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
matrix = {
|
|
125
|
+
(norm(r.region), norm(r.disaster_type), norm(r.solution_type)): r
|
|
126
|
+
for r in plan.grant_matrix
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
rounds: list[TrancheRound] = []
|
|
130
|
+
for tranche in range(1, max_tranche_count(plan) + 1):
|
|
131
|
+
required = {ll: 0 for ll in local_levels}
|
|
132
|
+
unassigned = 0 # beneficiaries who did not name a local level
|
|
133
|
+
stages: set[str] = set()
|
|
134
|
+
|
|
135
|
+
for b in district_counts:
|
|
136
|
+
row = matrix[(norm(b.region), norm(
|
|
137
|
+
b.disaster_type), norm(b.solution_type))]
|
|
138
|
+
if tranche > len(row.tranches):
|
|
139
|
+
# this grant has no payment in this round (its amount is Rs. 0)
|
|
140
|
+
continue
|
|
141
|
+
payment = row.tranches[tranche - 1]
|
|
142
|
+
stages.add(payment.stage)
|
|
143
|
+
amount = b.count * payment.amount
|
|
144
|
+
if b.local_level:
|
|
145
|
+
required[b.local_level] += amount
|
|
146
|
+
else:
|
|
147
|
+
unassigned += amount
|
|
148
|
+
|
|
149
|
+
# Money for beneficiaries with no local level is shared equally between the
|
|
150
|
+
# district's local levels. Any odd rupees go to the first ones in the plan.
|
|
151
|
+
if unassigned:
|
|
152
|
+
for ll, share in zip(local_levels, split_by_weights(unassigned, [1] * len(local_levels))):
|
|
153
|
+
required[ll] += share
|
|
154
|
+
|
|
155
|
+
total = sum(required.values())
|
|
156
|
+
federal, province, local = split_liability(plan, total)
|
|
157
|
+
local_shares = dict(
|
|
158
|
+
zip(local_levels, split_by_weights(
|
|
159
|
+
local, [required[ll] for ll in local_levels]))
|
|
160
|
+
)
|
|
161
|
+
rounds.append(
|
|
162
|
+
TrancheRound(
|
|
163
|
+
tranche=tranche,
|
|
164
|
+
stages=sorted(stages),
|
|
165
|
+
required=required,
|
|
166
|
+
total=total,
|
|
167
|
+
federal=federal,
|
|
168
|
+
province=province,
|
|
169
|
+
local=local,
|
|
170
|
+
local_shares=local_shares,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return DistrictSchedule(
|
|
175
|
+
name=district.name,
|
|
176
|
+
code=district.code,
|
|
177
|
+
province=province_name,
|
|
178
|
+
province_code=province_code,
|
|
179
|
+
region=district.region,
|
|
180
|
+
local_levels=local_levels,
|
|
181
|
+
local_level_codes=local_level_codes,
|
|
182
|
+
rounds=rounds,
|
|
183
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
|
|
2
|
+
|
|
3
|
+
from fm_calculation_engine.common import (
|
|
4
|
+
BeneficiaryCount,
|
|
5
|
+
EngineInputError,
|
|
6
|
+
parse,
|
|
7
|
+
validate_beneficiary_counts,
|
|
8
|
+
)
|
|
9
|
+
from fm_calculation_engine.core import build_district_schedule
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DistrictProfile(StrictModel):
|
|
13
|
+
"""Input profile for District fund calculation (accepts district name or code)."""
|
|
14
|
+
|
|
15
|
+
plan_id: NonEmptyStr
|
|
16
|
+
district_name: NonEmptyStr
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DistrictCollectible(StrictModel):
|
|
20
|
+
"""Breakdown of funds collected from Federal, Province, and Local government levels."""
|
|
21
|
+
|
|
22
|
+
federal: int
|
|
23
|
+
province: int
|
|
24
|
+
local: int
|
|
25
|
+
total: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LocalGovDisbursement(StrictModel):
|
|
29
|
+
"""Total fund to be forwarded to a specific local government for a tranche."""
|
|
30
|
+
|
|
31
|
+
local_gov: str
|
|
32
|
+
amount: int
|
|
33
|
+
local_gov_code: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DistrictTrancheOutput(StrictModel):
|
|
37
|
+
"""Collectible and disbursement amounts for a district in a single tranche round."""
|
|
38
|
+
|
|
39
|
+
tranche: int
|
|
40
|
+
stages: list[str]
|
|
41
|
+
collectible: DistrictCollectible
|
|
42
|
+
disbursements: list[LocalGovDisbursement]
|
|
43
|
+
total_amount: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DistrictOutput(StrictModel):
|
|
47
|
+
"""Fund collection and disbursement responsibility for a district."""
|
|
48
|
+
|
|
49
|
+
plan_id: str
|
|
50
|
+
district_name: str
|
|
51
|
+
province_name: str
|
|
52
|
+
region: str
|
|
53
|
+
total_beneficiaries: int
|
|
54
|
+
tranches: list[DistrictTrancheOutput]
|
|
55
|
+
grand_total: int
|
|
56
|
+
district_code: str | None = None
|
|
57
|
+
province_code: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def calculate_district_fund(
|
|
61
|
+
plan: ImplementationPlan,
|
|
62
|
+
profile: DistrictProfile | dict,
|
|
63
|
+
beneficiary_counts: list[BeneficiaryCount | dict],
|
|
64
|
+
) -> DistrictOutput:
|
|
65
|
+
"""Calculate collectible amounts from Fed/Prov/Local and forwarding amounts to local governments."""
|
|
66
|
+
prof = parse(DistrictProfile, profile)
|
|
67
|
+
|
|
68
|
+
if norm(prof.plan_id) != norm(plan.plan_id):
|
|
69
|
+
raise EngineInputError(
|
|
70
|
+
f"plan_id '{prof.plan_id}' does not match this plan ('{plan.plan_id}')"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
district_obj = plan.government.find_district(prof.district_name)
|
|
74
|
+
if district_obj is None:
|
|
75
|
+
district_names = [d.name for d in plan.government.districts]
|
|
76
|
+
raise EngineInputError(
|
|
77
|
+
f"District '{prof.district_name}' was not found in the plan's districts {district_names}."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
valid_counts = validate_beneficiary_counts(plan, beneficiary_counts)
|
|
81
|
+
district_counts = [b for b in valid_counts if norm(
|
|
82
|
+
b.district) == norm(district_obj.name)]
|
|
83
|
+
|
|
84
|
+
schedule = build_district_schedule(plan, district_obj.name, district_counts)
|
|
85
|
+
|
|
86
|
+
tranche_outputs = [
|
|
87
|
+
DistrictTrancheOutput(
|
|
88
|
+
tranche=r.tranche,
|
|
89
|
+
stages=r.stages,
|
|
90
|
+
collectible=DistrictCollectible(
|
|
91
|
+
federal=r.federal,
|
|
92
|
+
province=r.province,
|
|
93
|
+
local=r.local,
|
|
94
|
+
total=r.total,
|
|
95
|
+
),
|
|
96
|
+
disbursements=[
|
|
97
|
+
LocalGovDisbursement(
|
|
98
|
+
local_gov=ll,
|
|
99
|
+
local_gov_code=schedule.local_level_codes.get(ll),
|
|
100
|
+
amount=r.required[ll],
|
|
101
|
+
)
|
|
102
|
+
for ll in schedule.local_levels
|
|
103
|
+
],
|
|
104
|
+
total_amount=r.total,
|
|
105
|
+
)
|
|
106
|
+
for r in schedule.rounds
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
return DistrictOutput(
|
|
110
|
+
plan_id=plan.plan_id,
|
|
111
|
+
district_name=schedule.name,
|
|
112
|
+
district_code=schedule.code,
|
|
113
|
+
province_name=schedule.province,
|
|
114
|
+
province_code=schedule.province_code,
|
|
115
|
+
region=schedule.region,
|
|
116
|
+
total_beneficiaries=sum(b.count for b in district_counts),
|
|
117
|
+
tranches=tranche_outputs,
|
|
118
|
+
grand_total=sum(r.total for r in schedule.rounds),
|
|
119
|
+
)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
from fm_plan import ImplementationPlan, NonEmptyStr, StrictModel, norm
|
|
4
|
+
|
|
5
|
+
from fm_calculation_engine.common import (
|
|
6
|
+
BeneficiaryCount,
|
|
7
|
+
Donation,
|
|
8
|
+
EngineInputError,
|
|
9
|
+
parse,
|
|
10
|
+
validate_beneficiary_counts,
|
|
11
|
+
)
|
|
12
|
+
from fm_calculation_engine.core import (
|
|
13
|
+
build_district_schedule,
|
|
14
|
+
max_tranche_count,
|
|
15
|
+
split_by_weights,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GovEntityProfile(StrictModel):
|
|
20
|
+
"""Input profile for Federal, Provincial, or Local government entity."""
|
|
21
|
+
|
|
22
|
+
plan_id: NonEmptyStr
|
|
23
|
+
gov_entity_name: NonEmptyStr
|
|
24
|
+
# "federal", "province", or "local" (optional: auto-detected if omitted)
|
|
25
|
+
gov_type: NonEmptyStr | None = None
|
|
26
|
+
# Only for a local government whose name is used in more than one district.
|
|
27
|
+
district: NonEmptyStr | None = None
|
|
28
|
+
donations: list[Donation] = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GovDistrictPayment(StrictModel):
|
|
32
|
+
"""Payment responsibility towards a specific district for a single tranche."""
|
|
33
|
+
|
|
34
|
+
district: str
|
|
35
|
+
gross_liability: int
|
|
36
|
+
donation_offset: int
|
|
37
|
+
net_payable: int
|
|
38
|
+
district_code: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GovTrancheLiability(StrictModel):
|
|
42
|
+
"""Fund liability breakdown for a government entity for a specific tranche round."""
|
|
43
|
+
|
|
44
|
+
tranche: int
|
|
45
|
+
stages: list[str]
|
|
46
|
+
gross_liability: int
|
|
47
|
+
donation_applied: int
|
|
48
|
+
net_payable: int
|
|
49
|
+
remaining_donation: int
|
|
50
|
+
districts: list[GovDistrictPayment]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BeneficiaryGroupSummary(StrictModel):
|
|
54
|
+
"""Summary count of beneficiaries by disaster, region, and solution type under this entity."""
|
|
55
|
+
|
|
56
|
+
disaster_type: str
|
|
57
|
+
region: str
|
|
58
|
+
solution_type: str
|
|
59
|
+
count: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GovernmentLiabilityOutput(StrictModel):
|
|
63
|
+
"""Complete liability and disbursement responsibility for a government entity."""
|
|
64
|
+
|
|
65
|
+
plan_id: str
|
|
66
|
+
gov_entity_name: str
|
|
67
|
+
gov_type: str # "federal", "province", or "local"
|
|
68
|
+
liability_percentage: float
|
|
69
|
+
total_donations_received: int
|
|
70
|
+
donations: list[Donation] # oldest first; donations with no date come last
|
|
71
|
+
total_beneficiaries: int
|
|
72
|
+
# Local governments only: households in the same district that did not name a local
|
|
73
|
+
# level. They are not counted in total_beneficiaries, but their money is shared
|
|
74
|
+
# equally between the district's local levels, so it is part of the liability.
|
|
75
|
+
unassigned_beneficiaries: int = 0
|
|
76
|
+
beneficiary_summary: list[BeneficiaryGroupSummary]
|
|
77
|
+
tranches: list[GovTrancheLiability]
|
|
78
|
+
total_gross_liability: int
|
|
79
|
+
total_donation_applied: int
|
|
80
|
+
total_net_payable: int
|
|
81
|
+
final_remaining_donation: int
|
|
82
|
+
gov_entity_code: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_entity(
|
|
86
|
+
plan: ImplementationPlan,
|
|
87
|
+
entity_name: str,
|
|
88
|
+
requested_type: str | None = None,
|
|
89
|
+
district: str | None = None,
|
|
90
|
+
):
|
|
91
|
+
"""Find which government this is (by name or code). Returns (type, canonical name, code, its district names)."""
|
|
92
|
+
federal = plan.government.federal
|
|
93
|
+
canonical_code = None
|
|
94
|
+
|
|
95
|
+
if norm(entity_name) == norm(federal.name) or norm(entity_name) == norm(federal.code):
|
|
96
|
+
entity_type = "federal"
|
|
97
|
+
canonical_name = federal.name
|
|
98
|
+
canonical_code = federal.code
|
|
99
|
+
managed_districts = [d.name for d in plan.government.districts]
|
|
100
|
+
elif (prov_obj := plan.government.find_province(entity_name)) is not None:
|
|
101
|
+
entity_type = "province"
|
|
102
|
+
canonical_name = prov_obj.name
|
|
103
|
+
canonical_code = prov_obj.code
|
|
104
|
+
managed_districts = [
|
|
105
|
+
d.name
|
|
106
|
+
for d in plan.government.districts
|
|
107
|
+
if norm(d.province) == norm(prov_obj.code)
|
|
108
|
+
]
|
|
109
|
+
else:
|
|
110
|
+
# Check local levels
|
|
111
|
+
dist_filter_name = None
|
|
112
|
+
if district is not None:
|
|
113
|
+
dist_filter = plan.government.find_district(district)
|
|
114
|
+
if dist_filter is None:
|
|
115
|
+
raise EngineInputError(f"District '{district}' is not in this plan's districts.")
|
|
116
|
+
dist_filter_name = dist_filter.name
|
|
117
|
+
|
|
118
|
+
locals_found = plan.government.find_local_level(entity_name, district_identifier=dist_filter_name)
|
|
119
|
+
if not locals_found:
|
|
120
|
+
# Check if entity exists in another district when district was given
|
|
121
|
+
if district is not None:
|
|
122
|
+
all_matching = plan.government.find_local_level(entity_name)
|
|
123
|
+
if all_matching:
|
|
124
|
+
raise EngineInputError(
|
|
125
|
+
f"Local government '{all_matching[0].name}' is not in district '{district}' in this plan."
|
|
126
|
+
)
|
|
127
|
+
raise EngineInputError(
|
|
128
|
+
f"Government entity '{entity_name}' was not found in the plan's government structure."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
entity_type = "local"
|
|
132
|
+
canonical_name = locals_found[0].name
|
|
133
|
+
canonical_code = locals_found[0].code
|
|
134
|
+
managed_districts = [
|
|
135
|
+
(plan.government.find_district(ll.district).name if plan.government.find_district(ll.district) else ll.district)
|
|
136
|
+
for ll in locals_found
|
|
137
|
+
]
|
|
138
|
+
if len(set(managed_districts)) > 1:
|
|
139
|
+
raise EngineInputError(
|
|
140
|
+
f"'{canonical_name}' is a local government in more than one district "
|
|
141
|
+
f"{managed_districts}. Say which one with the 'district' field."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if district is not None and entity_type != "local":
|
|
145
|
+
raise EngineInputError(
|
|
146
|
+
"'district' can only be given for a local government "
|
|
147
|
+
f"('{canonical_name}' is a {entity_type} government)."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if requested_type is not None and norm(requested_type) != norm(entity_type):
|
|
151
|
+
raise EngineInputError(
|
|
152
|
+
f"Specified gov_type '{requested_type}' does not match entity '{entity_name}' (expected '{entity_type}')."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return entity_type, canonical_name, canonical_code, managed_districts
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def calculate_government_liability(
|
|
159
|
+
plan: ImplementationPlan,
|
|
160
|
+
profile: GovEntityProfile | dict,
|
|
161
|
+
beneficiary_counts: list[BeneficiaryCount | dict],
|
|
162
|
+
) -> GovernmentLiabilityOutput:
|
|
163
|
+
"""Calculate government liability, donation offsets across tranches, and district breakdowns."""
|
|
164
|
+
prof = parse(GovEntityProfile, profile)
|
|
165
|
+
|
|
166
|
+
if norm(prof.plan_id) != norm(plan.plan_id):
|
|
167
|
+
raise EngineInputError(
|
|
168
|
+
f"plan_id '{prof.plan_id}' does not match this plan ('{plan.plan_id}')"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
entity_type, canonical_name, canonical_code, managed_districts = _resolve_entity(
|
|
172
|
+
plan, prof.gov_entity_name, prof.gov_type, prof.district
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
valid_counts = validate_beneficiary_counts(plan, beneficiary_counts)
|
|
176
|
+
|
|
177
|
+
# Counts inside this government's districts
|
|
178
|
+
managed_norms = {norm(d) for d in managed_districts}
|
|
179
|
+
in_scope = [b for b in valid_counts if norm(b.district) in managed_norms]
|
|
180
|
+
|
|
181
|
+
if entity_type == "local":
|
|
182
|
+
relevant_counts = [
|
|
183
|
+
b for b in in_scope if b.local_level and norm(b.local_level) == norm(canonical_name)
|
|
184
|
+
]
|
|
185
|
+
unassigned_beneficiaries = sum(
|
|
186
|
+
b.count for b in in_scope if not b.local_level)
|
|
187
|
+
else:
|
|
188
|
+
relevant_counts = in_scope
|
|
189
|
+
unassigned_beneficiaries = 0
|
|
190
|
+
|
|
191
|
+
liability = plan.government_liability
|
|
192
|
+
liability_pct = {
|
|
193
|
+
"federal": liability.federal,
|
|
194
|
+
"province": liability.province,
|
|
195
|
+
"local": liability.local,
|
|
196
|
+
}[entity_type]
|
|
197
|
+
|
|
198
|
+
# Beneficiary summary (names are already in the plan's spelling and region comes from the plan)
|
|
199
|
+
grouped: dict[tuple[str, str, str], int] = {}
|
|
200
|
+
for b in relevant_counts:
|
|
201
|
+
key = (b.disaster_type, b.region, b.solution_type)
|
|
202
|
+
grouped[key] = grouped.get(key, 0) + b.count
|
|
203
|
+
summary_list = [
|
|
204
|
+
BeneficiaryGroupSummary(
|
|
205
|
+
disaster_type=dis, region=reg, solution_type=sol, count=n)
|
|
206
|
+
for (dis, reg, sol), n in grouped.items()
|
|
207
|
+
]
|
|
208
|
+
total_beneficiaries = sum(b.count for b in relevant_counts)
|
|
209
|
+
|
|
210
|
+
# Districts with beneficiaries, in the plan's order
|
|
211
|
+
active_districts = [
|
|
212
|
+
d.name
|
|
213
|
+
for d in plan.government.districts
|
|
214
|
+
if norm(d.name) in managed_norms and any(norm(b.district) == norm(d.name) for b in in_scope)
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
# One schedule per district: the same numbers the district output uses
|
|
218
|
+
schedules = {
|
|
219
|
+
d: build_district_schedule(
|
|
220
|
+
plan, d, [b for b in in_scope if norm(b.district) == norm(d)]
|
|
221
|
+
)
|
|
222
|
+
for d in active_districts
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def share_of_round(district: str, tranche_round) -> int:
|
|
226
|
+
if entity_type == "federal":
|
|
227
|
+
return tranche_round.federal
|
|
228
|
+
if entity_type == "province":
|
|
229
|
+
return tranche_round.province
|
|
230
|
+
local_level = next(
|
|
231
|
+
ll for ll in schedules[district].local_levels if norm(ll) == norm(canonical_name)
|
|
232
|
+
)
|
|
233
|
+
return tranche_round.local_shares[local_level]
|
|
234
|
+
|
|
235
|
+
# Donations are pooled and spent from tranche 1 onward; the surplus rolls over.
|
|
236
|
+
donations = sorted(
|
|
237
|
+
prof.donations, key=lambda d: (
|
|
238
|
+
d.received_date is None, d.received_date or date.min)
|
|
239
|
+
)
|
|
240
|
+
total_donations_received = sum(d.amount for d in donations)
|
|
241
|
+
available_donation = total_donations_received
|
|
242
|
+
|
|
243
|
+
tranche_outputs: list[GovTrancheLiability] = []
|
|
244
|
+
total_gross = 0
|
|
245
|
+
total_donations_used = 0
|
|
246
|
+
|
|
247
|
+
for index in range(max_tranche_count(plan)):
|
|
248
|
+
district_gross = {d: share_of_round(
|
|
249
|
+
d, schedules[d].rounds[index]) for d in active_districts}
|
|
250
|
+
stages = sorted(
|
|
251
|
+
{s for d in active_districts for s in schedules[d].rounds[index].stages})
|
|
252
|
+
|
|
253
|
+
tranche_gross = sum(district_gross.values())
|
|
254
|
+
donation_applied = min(available_donation, tranche_gross)
|
|
255
|
+
net_payable = tranche_gross - donation_applied
|
|
256
|
+
available_donation -= donation_applied
|
|
257
|
+
|
|
258
|
+
total_gross += tranche_gross
|
|
259
|
+
total_donations_used += donation_applied
|
|
260
|
+
|
|
261
|
+
# The donation relief is shared between districts in proportion to what each one needs
|
|
262
|
+
offsets = split_by_weights(
|
|
263
|
+
donation_applied, [district_gross[d] for d in active_districts])
|
|
264
|
+
district_payments = [
|
|
265
|
+
GovDistrictPayment(
|
|
266
|
+
district=d,
|
|
267
|
+
district_code=schedules[d].code,
|
|
268
|
+
gross_liability=district_gross[d],
|
|
269
|
+
donation_offset=offset,
|
|
270
|
+
net_payable=district_gross[d] - offset,
|
|
271
|
+
)
|
|
272
|
+
for d, offset in zip(active_districts, offsets)
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
tranche_outputs.append(
|
|
276
|
+
GovTrancheLiability(
|
|
277
|
+
tranche=index + 1,
|
|
278
|
+
stages=stages,
|
|
279
|
+
gross_liability=tranche_gross,
|
|
280
|
+
donation_applied=donation_applied,
|
|
281
|
+
net_payable=net_payable,
|
|
282
|
+
remaining_donation=available_donation,
|
|
283
|
+
districts=district_payments,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return GovernmentLiabilityOutput(
|
|
288
|
+
plan_id=plan.plan_id,
|
|
289
|
+
gov_entity_name=canonical_name,
|
|
290
|
+
gov_entity_code=canonical_code,
|
|
291
|
+
gov_type=entity_type,
|
|
292
|
+
liability_percentage=liability_pct,
|
|
293
|
+
total_donations_received=total_donations_received,
|
|
294
|
+
donations=donations,
|
|
295
|
+
total_beneficiaries=total_beneficiaries,
|
|
296
|
+
unassigned_beneficiaries=unassigned_beneficiaries,
|
|
297
|
+
beneficiary_summary=summary_list,
|
|
298
|
+
tranches=tranche_outputs,
|
|
299
|
+
total_gross_liability=total_gross,
|
|
300
|
+
total_donation_applied=total_donations_used,
|
|
301
|
+
total_net_payable=total_gross - total_donations_used,
|
|
302
|
+
final_remaining_donation=available_donation,
|
|
303
|
+
)
|
|
File without changes
|