Skip to content
This repository was archived by the owner on Jan 14, 2020. It is now read-only.

Commit 5eb6c70

Browse files
authored
Merge pull request #27 from fielded/dynamic-calculations
Dynamic calculation of allocations
2 parents 7a0b783 + 04371f4 commit 5eb6c70

6 files changed

+621
-262
lines changed

src/config/coefficients.json

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{
2+
"versions": [
3+
{
4+
"version": 1,
5+
"date": "2016-01-01",
6+
"coefficients": {
7+
"product:2-reconst-syg": {
8+
"wastage": 1.1,
9+
"coverage": 0.87
10+
},
11+
"product:5-reconst-syg": {
12+
"wastage": 1.1,
13+
"coverage": 0.87
14+
},
15+
"product:ad-syg": {
16+
"wastage": 1.05,
17+
"coverage": 0.87
18+
},
19+
"product:bcg": {
20+
"wastage": 2,
21+
"coverage": 0.9,
22+
"doses": 1
23+
},
24+
"product:bcg-syg": {
25+
"coverage": 0.87
26+
},
27+
"product:diluent-bcg": {
28+
},
29+
"product:diluent-mv": {
30+
},
31+
"product:diluent-yf": {
32+
},
33+
"product:hep-b": {
34+
"wastage": 1.3333333333333333,
35+
"coverage": 0.9,
36+
"doses": 1
37+
},
38+
"product:hpv": {
39+
"wastage": 1.1,
40+
"coverage": 0.9,
41+
"doses": 2
42+
},
43+
"product:ipv": {
44+
"wastage": 1.05,
45+
"coverage": 0.87,
46+
"doses": 1
47+
},
48+
"product:mv": {
49+
"wastage": 1.4285714285714286,
50+
"coverage": 0.9,
51+
"doses": 1
52+
},
53+
"product:opv": {
54+
"wastage": 1.3333333333333333,
55+
"coverage": 0.9,
56+
"doses": 4
57+
},
58+
"product:pcv": {
59+
"wastage": 1.05,
60+
"coverage": 0.87,
61+
"doses": 3
62+
},
63+
"product:penta": {
64+
"wastage": 1.3333333333333333,
65+
"coverage": 0.87,
66+
"doses": 3
67+
},
68+
"product:rota": {
69+
"wastage": 1.05,
70+
"coverage": 0.87,
71+
"doses": 3
72+
},
73+
"product:safety-boxes": {
74+
"wastage": 1.05
75+
},
76+
"product:td": {
77+
"wastage": 1.3333333333333333,
78+
"coverage": 0.87,
79+
"doses": 2
80+
},
81+
"product:yf": {
82+
"wastage": 1.4285714285714286,
83+
"coverage": 0.9,
84+
"doses": 1
85+
}
86+
}
87+
}
88+
]
89+
}

src/factor-extractor.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* global moment:false */
2+
import config from './config/config.json'
3+
import { find } from './utils.js'
4+
import calculateWeeklyLevels from './weekly-levels-calculator'
5+
6+
const isVersion = (date, version) => {
7+
const momentDate = moment().isoWeekYear(date.year).isoWeek(date.week).isoWeekday(1).startOf('day')
8+
const momentVersionStartDate = moment(version.date, config.versionDateFormat).startOf('isoWeek').startOf('day')
9+
return momentDate.isSameOrAfter(momentVersionStartDate)
10+
}
11+
12+
const getFactor = (versions, date) => {
13+
const reverseVersions = versions.slice(0).reverse()
14+
const factor = find(reverseVersions, isVersion.bind(null, date))
15+
// If the doc is too old to have a matching version, default to the oldest one
16+
if (!factor) {
17+
return versions[0]
18+
}
19+
return factor
20+
}
21+
22+
const getCoefficients = (productCoefficients, date) => {
23+
if (!(productCoefficients && productCoefficients.versions && productCoefficients.versions.length)) {
24+
throw new Error('missing productCoefficients or productCoefficients.versions')
25+
}
26+
27+
const version = getFactor(productCoefficients.versions, date)
28+
if (!(version && version.coefficients)) {
29+
throw new Error(`cannot find version of coefficients for date ${date}`)
30+
}
31+
return version.coefficients
32+
}
33+
34+
const getWeeksOfStock = (location, date) => {
35+
if (!(location.plans && location.plans.length)) {
36+
throw new Error(`missing plans on location ${location._id}`)
37+
}
38+
39+
const plans = getFactor(location.plans, date)
40+
if (!(plans && plans.weeksOfStock)) {
41+
throw new Error(`cannot find version of weeksOfStock for location ${location._id} and date ${date}`)
42+
}
43+
return plans.weeksOfStock
44+
}
45+
46+
const getTargetPopulations = (location, date) => {
47+
if (location.targetPopulations && location.targetPopulations.length) {
48+
const targetPopulations = getFactor(location.targetPopulations, date)
49+
50+
return {
51+
version: targetPopulations.version,
52+
monthlyTargetPopulations: targetPopulations && targetPopulations.monthlyTargetPopulations
53+
}
54+
}
55+
56+
// For backwards compatibility to version before introducing `targetPopulations`,
57+
// since we have no control about when the dashboards are going
58+
// to replicate the new location docs
59+
if (!(location.targetPopulation && Object.keys(location.targetPopulation).length)) {
60+
return {
61+
version: 1
62+
}
63+
}
64+
65+
return {
66+
version: 1,
67+
monthlyTargetPopulations: location.targetPopulation
68+
}
69+
}
70+
71+
const getWeeklyLevels = (location, date) => {
72+
if (!(location.allocations && location.allocations.length)) {
73+
throw new Error(`missing allocations on location ${location._id}`)
74+
}
75+
76+
const allocations = getFactor(location.allocations, date)
77+
if (!(allocations && allocations.weeklyLevels)) {
78+
throw new Error(`cannot find version of weeklyLevels for location ${location._id} and date ${date}`)
79+
}
80+
return allocations.weeklyLevels
81+
}
82+
83+
export default (location, productCoefficients, date) => {
84+
const weeksOfStock = getWeeksOfStock(location, date)
85+
const { version, monthlyTargetPopulations } = getTargetPopulations(location, date)
86+
87+
// For backwards compatibility to version before introducing `targetPopulations`,
88+
// since for that version `weeklyAllocations` were not always calculated
89+
// based on target population
90+
if (version === 1) {
91+
return {
92+
weeklyLevels: getWeeklyLevels(location, date),
93+
weeksOfStock,
94+
monthlyTargetPopulations
95+
}
96+
}
97+
98+
const coefficients = getCoefficients(productCoefficients, date)
99+
const weeklyLevels = calculateWeeklyLevels(monthlyTargetPopulations, coefficients)
100+
101+
return {
102+
weeklyLevels,
103+
weeksOfStock,
104+
monthlyTargetPopulations
105+
}
106+
}

src/thresholds.service.js

Lines changed: 45 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,6 @@
1-
/* global moment:false */
2-
import config from './config/config.json'
3-
4-
// TODO: replace with Array#find ponyfill
5-
const find = (list, match) => {
6-
for (let i = 0; i < list.length; i++) {
7-
if (match(list[i])) {
8-
return list[i]
9-
}
10-
}
11-
return undefined
12-
}
13-
14-
const isVersion = (date, version) => {
15-
const momentDate = moment().isoWeekYear(date.year).isoWeek(date.week).isoWeekday(1).startOf('day')
16-
const momentVersionStartDate = moment(version.date, config.versionDateFormat).startOf('isoWeek').startOf('day')
17-
return momentDate.isSameOrAfter(momentVersionStartDate)
18-
}
19-
20-
const isId = (id, item) => item._id === id
21-
22-
const getFactor = (versions, date) => {
23-
const reverseVersions = versions.slice(0).reverse()
24-
const factor = find(reverseVersions, isVersion.bind(null, date))
25-
// If the doc is too old to have a matching version, default to the oldest one
26-
if (!factor) {
27-
return versions[0]
28-
}
29-
return factor
30-
}
31-
32-
const getFactors = (stockCount, location) => {
33-
// centralized for whenever we implement #16
34-
const somethingIsWrong = () => undefined
35-
36-
const getWeeklyLevels = () => {
37-
if (!(location.allocations && location.allocations.length)) {
38-
somethingIsWrong()
39-
}
40-
41-
const allocations = getFactor(location.allocations, stockCount.date)
42-
return allocations && allocations.weeklyLevels
43-
}
44-
45-
const getWeeksOfStock = () => {
46-
if (!(location.plans && location.plans.length)) {
47-
somethingIsWrong()
48-
}
49-
50-
const plans = getFactor(location.plans, stockCount.date)
51-
return plans && plans.weeksOfStock
52-
}
53-
54-
const getMonthlyTargetPopulations = () => {
55-
let monthlyTargetPopulations
56-
if (location.targetPopulations) {
57-
if (!location.targetPopulations.length) {
58-
somethingIsWrong()
59-
}
60-
61-
const targetPopulations = getFactor(location.targetPopulations, stockCount.date)
62-
monthlyTargetPopulations = targetPopulations && targetPopulations.monthlyTargetPopulations
63-
} else {
64-
// For backwards compatibility with the old style location docs,
65-
// since we have no control about when the dashboards are going
66-
// to replicate the new location docs
67-
if (!(location.targetPopulation && location.targetPopulation.length)) {
68-
somethingIsWrong()
69-
}
70-
monthlyTargetPopulations = location.targetPopulation
71-
}
72-
return monthlyTargetPopulations
73-
}
74-
75-
return {
76-
weeksOfStock: getWeeksOfStock(),
77-
weeklyLevels: getWeeklyLevels(),
78-
targetPopulations: getMonthlyTargetPopulations()
79-
}
80-
}
1+
import defaultCoefficients from './config/coefficients.json'
2+
import { find, somethingIsWrong } from './utils.js'
3+
import getFactors from './factor-extractor.js'
814

825
class ThresholdsService {
836
constructor ($q, smartId, lgasService, statesService) {
@@ -90,38 +13,53 @@ class ThresholdsService {
9013
// For zones the thresholds are based on the state store required allocation for
9114
// the week, that information is passed as an optional param (`requiredStateStoresAllocation`).
9215
// That param is only used for zones.
93-
calculateThresholds (location, stockCount, products, requiredStateStoresAllocation = {}) {
94-
if (!(stockCount && stockCount.date)) {
95-
return
16+
//
17+
// Passing the coefficientVersions as a param so that it can be adapted later to use the database doc
18+
calculateThresholds (location, stockCount, products, requiredStateStoresAllocation = {}, productCoefficients = defaultCoefficients) {
19+
if (!stockCount) {
20+
const locationId = location && location._id ? location._id : 'with unknown id'
21+
return somethingIsWrong(`missing mandatory param stock count for location ${locationId}`)
22+
}
23+
if (!stockCount.date) {
24+
return somethingIsWrong(`missing date on stock count ${stockCount._id}`)
9625
}
9726

98-
if (!(location && location.level)) {
99-
return
27+
if (!location) {
28+
const stockCountId = stockCount && stockCount._id ? stockCount._id : 'with unknown id'
29+
return somethingIsWrong(`missing mandatory param location for stock count ${stockCountId}`)
30+
}
31+
if (!location.level) {
32+
return somethingIsWrong(`missing level on location ${location._id}`)
10033
}
10134

10235
if (!(products && products.length)) {
103-
return
36+
return somethingIsWrong('missing mandatory param products')
10437
}
10538

106-
const { weeklyLevels, weeksOfStock, targetPopulations } = getFactors(stockCount, location)
107-
108-
if (!(weeklyLevels && weeksOfStock)) {
39+
let locationFactors
40+
try {
41+
locationFactors = getFactors(location, productCoefficients, stockCount.date)
42+
} catch (e) {
43+
somethingIsWrong(e.message)
10944
return
11045
}
11146

112-
return Object.keys(weeklyLevels).reduce((index, productId) => {
113-
index[productId] = Object.keys(weeksOfStock).reduce((productThresholds, threshold) => {
114-
const level = weeklyLevels[productId] * weeksOfStock[threshold]
115-
const product = find(products, isId.bind(null, productId))
47+
const { weeksOfStock, weeklyLevels, monthlyTargetPopulations } = locationFactors
11648

117-
// Default rounding used in VSPMD and highest possible presentation
118-
let presentation = 20
49+
return products.reduce((index, product) => {
50+
const productId = product._id
51+
const weeklyLevel = weeklyLevels[productId]
11952

120-
if (product && product.presentation) {
121-
// TODO: product presentations should be ints, not strings
122-
presentation = parseInt(product.presentation, 10)
123-
}
53+
// Default rounding used in VSPMD and highest possible presentation
54+
let presentation = 20
12455

56+
if (product && product.presentation) {
57+
// TODO: product presentations should be ints, not strings
58+
presentation = parseInt(product.presentation, 10)
59+
}
60+
61+
index[productId] = Object.keys(weeksOfStock).reduce((productThresholds, threshold) => {
62+
const level = weeklyLevel * weeksOfStock[threshold]
12563
const roundedLevel = Math.ceil(level / presentation) * presentation
12664
productThresholds[threshold] = roundedLevel
12765

@@ -132,15 +70,19 @@ class ThresholdsService {
13270
return productThresholds
13371
}, {})
13472

135-
if (targetPopulations) { // old (and new?) zone docs have no target population doc
136-
index[productId].targetPopulation = targetPopulations[productId]
73+
index[productId].weeklyLevel = weeklyLevel
74+
75+
if (monthlyTargetPopulations) { // old zone docs have no target population
76+
index[productId].targetPopulation = monthlyTargetPopulations[productId]
13777
}
13878

13979
return index
14080
}, {})
14181
}
14282

143-
getThresholdsFor (stockCounts, products) {
83+
getThresholdsFor (stockCounts, products, productCoefficients = defaultCoefficients) {
84+
const isId = (id, item) => item._id === id
85+
14486
// TODO: make it work for zones too.
14587
// For making it work with zones, we need to take into account the amount of stock
14688
// to be allocated to the zone state stores in a particular week
@@ -176,7 +118,7 @@ class ThresholdsService {
176118
Object.keys(index).forEach((key) => {
177119
const item = index[key]
178120
const location = find(promisesRes[item.type], isId.bind(null, key))
179-
item.thresholds = this.calculateThresholds(location, item, products)
121+
item.thresholds = this.calculateThresholds(location, item, products, null, productCoefficients)
180122
delete item.type
181123
})
182124

0 commit comments

Comments
 (0)