UNPKG

@wmfs/tymly-rankings-plugin

Version:

Plugin which handles ranking of data for Tymly framework

125 lines (104 loc) 5.4 kB
const { snakeCase, kebabCase, isObject } = require('lodash') const dist = require('distributions') const generateViewStatement = require('./generate-view-statement') class RankingService { async boot (options) { this.logger = options.bootedServices.logger.child('service:rankings') const client = options.bootedServices.storage.client const rankings = options.blueprintComponents.rankings this.client = client this.rankings = rankings if (!isObject(rankings)) { options.messages.info('No rankings to find') return } const statsViewSQL = {} options.messages.info('Finding rankings') for (const key of Object.keys(rankings)) { const value = rankings[key] if (value.source && value.factors && options.bootedServices.registry.registry[key]) { const factors = Object.keys(value.factors) .filter(factor => Object.keys(options.bootedServices.registry.registry[key].value).includes(factor)) .reduce((obj, key) => { obj[key] = value.factors[key] return obj }, {}) const viewSQL = generateViewStatement({ category: snakeCase(value.name), schema: snakeCase(value.namespace), source: value.source, ranking: factors, registry: options.bootedServices.registry.registry[key] }) this.logger.debug(`${key} - View SQL \n ${viewSQL}`) await client.query(viewSQL) const statsViewKey = `${snakeCase(value.namespace)}.${snakeCase(value.rankingModel)}_stats` if (!statsViewSQL[statsViewKey]) statsViewSQL[statsViewKey] = [] // todo: median is missing but we don't use that yet statsViewSQL[statsViewKey].push( 'SELECT ' + `'${kebabCase(value.name)}' AS category, ` + 'COUNT(*) AS count, ' + 'ROUND(AVG(original_risk_score), 2) AS mean, ' + 'ROUND(VAR_POP(original_risk_score), 2) AS variance, ' + 'ROUND(STDDEV_POP(original_risk_score), 2) AS stdev, ' + 'CASE ' + 'WHEN COUNT(*) > 10000 ' + 'THEN CAST(\'{"veryLow": {"lowerBound": 0, "upperBound": \'|| ROUND(AVG(original_risk_score) - (2 * STDDEV_POP(original_risk_score)), 2) ||\'}, "low": {"lowerBound": \'|| ROUND(AVG(original_risk_score) - (2 * STDDEV_POP(original_risk_score)) + 0.01, 2) ||\', "upperBound": \'|| ROUND(AVG(original_risk_score) - STDDEV_POP(original_risk_score), 2) ||\'}, "medium": {"lowerBound": \'|| ROUND(AVG(original_risk_score) - STDDEV_POP(original_risk_score) + 0.01, 2) ||\', "upperBound": \'|| ROUND(AVG(original_risk_score) + STDDEV_POP(original_risk_score), 2) ||\'}, "high": {"lowerBound": \'|| ROUND(AVG(original_risk_score) + STDDEV_POP(original_risk_score) + 0.01, 2) ||\', "upperBound": \'|| ROUND(AVG(original_risk_score) + (2 * STDDEV_POP(original_risk_score)), 2) ||\'}, "veryHigh": {"lowerBound": \'|| ROUND(AVG(original_risk_score) + (2 * STDDEV_POP(original_risk_score)) + 0.01, 2) ||\', "upperBound": \'|| ROUND(MAX(original_risk_score), 2) ||\'}}\' AS jsonb) ' + 'ELSE CAST(\'{"veryLow": {"lowerBound": 0, "upperBound": \'|| ROUND(AVG(original_risk_score) - STDDEV_POP(original_risk_score), 2) ||\'}, "medium": {"lowerBound": \'|| ROUND(AVG(original_risk_score) - STDDEV_POP(original_risk_score) + 0.01, 2) ||\', "upperBound": \'|| ROUND(AVG(original_risk_score) + STDDEV_POP(original_risk_score), 2) ||\'}, "veryHigh": {"lowerBound": \'|| ROUND(AVG(original_risk_score) + STDDEV_POP(original_risk_score) + 0.01, 2) ||\', "upperBound": \'|| ROUND(MAX(original_risk_score), 2) ||\'}}\' AS jsonb) ' + 'END AS ranges ' + `FROM ${snakeCase(value.namespace)}.${snakeCase(value.name)}_scores` ) } } for (const [viewName, sql] of Object.entries(statsViewSQL)) { const statement = `CREATE OR REPLACE VIEW ${viewName} AS ${sql.join(' UNION ')};` this.logger.debug(`${viewName} - Stats SQL \n ${statement}`) await client.query(statement) } } async getStats (statsViewKey, category) { // category e.g. 'public-building' const statsRes = await this.client.query(`select mean::float, stdev::float, ranges from ${statsViewKey} where category = '${category}';`) if (statsRes.rows.length > 0) { const { mean, stdev } = statsRes.rows[0] const ranges = statsRes.rows[0].ranges || {} ranges.find = score => { for (const [name, range] of Object.entries(statsRes.rows[0].ranges)) { if (score >= range.lowerBound && score <= range.upperBound) { return name } } } return { mean, stdev, ranges } } else { return { mean: null, stdev: null, ranges: null } } } async findRange (statsViewKey, category, score) { if (category) { const { ranges } = await this.getStats(statsViewKey, category) if (ranges) { return ranges.find(score) } } } async findDistribution (statsViewKey, category, score) { if (category) { const { mean, stdev } = await this.getStats(statsViewKey, category) if (mean && stdev) { const normal = dist.Normal(mean, stdev) const pdf = normal.pdf(score) return Math.round(pdf * 10000) / 10000 } } } } module.exports = { serviceClass: RankingService, bootAfter: ['registry', 'storage', 'pgScript'] }