From 354bc2b04655072daeb8ba9e6806e4ca9b045970 Mon Sep 17 00:00:00 2001 From: Emile Rolley Date: Wed, 26 Feb 2025 12:09:35 +0100 Subject: [PATCH] refactor: use shallow copied engine to compute alternatives Instead of generating all publicodes rules from the combination from (size, motorisation and fuel) and using `contexte` (which is very costly in term of computation) do the combination at runtime and using `setSituation` on a shallow copied local engine. --- scripts/precompile.js | 28 +++---- src/CarSimulator.ts | 150 ++++++++++++++++++++++---------------- test/CarSimulator.test.ts | 92 +++++++++++++++++++++++ 3 files changed, 195 insertions(+), 75 deletions(-) diff --git a/scripts/precompile.js b/scripts/precompile.js index 8055713..b34aff2 100644 --- a/scripts/precompile.js +++ b/scripts/precompile.js @@ -1,18 +1,18 @@ import { writeFileSync } from "fs" import { join } from "path" -import { stringify } from "yaml" +// import { stringify } from "yaml" import { getModelFromSource } from "@publicodes/tools/compilation" import Engine from "publicodes" import getPersonas from "./compile-personas.js" -import generateAlternatives from "./generate-alternatives.js" +// import generateAlternatives from "./generate-alternatives.js" const ROOT_PATH = new URL(".", import.meta.url).pathname const SRC_FILES = join(ROOT_PATH, "../src/rules/") -const ALTERNATIVES_DEST_PATH = join( - ROOT_PATH, - "../src/rules/alternatives.publicodes", -) +// const ALTERNATIVES_DEST_PATH = join( +// ROOT_PATH, +// "../src/rules/alternatives.publicodes", +// ) const PERSONAS_DEST_PATH = join(ROOT_PATH, "../src/personas/personas.json") const model = getModelFromSource(SRC_FILES) @@ -25,14 +25,14 @@ const resolvedRules = Object.fromEntries( }), ) -const alternatives = generateAlternatives(resolvedRules) -console.log(`✅ './src/rules/alternatives.publicodes' generated`) -writeFileSync( - ALTERNATIVES_DEST_PATH, - `# GENERATED FILE - DO NOT EDIT\n\n${stringify(alternatives, { - aliasDuplicateObjects: false, - })}`, -) +// const alternatives = generateAlternatives(resolvedRules) +// console.log(`✅ './src/rules/alternatives.publicodes' generated`) +// writeFileSync( +// ALTERNATIVES_DEST_PATH, +// `# GENERATED FILE - DO NOT EDIT\n\n${stringify(alternatives, { +// aliasDuplicateObjects: false, +// })}`, +// ) const personas = getPersonas(resolvedRules) writeFileSync(PERSONAS_DEST_PATH, JSON.stringify(personas)) diff --git a/src/CarSimulator.ts b/src/CarSimulator.ts index 9d834a2..798ca4f 100644 --- a/src/CarSimulator.ts +++ b/src/CarSimulator.ts @@ -1,4 +1,5 @@ import Engine, { + Possibility, Situation as PublicodesSituation, serializeUnit, } from "publicodes" @@ -82,15 +83,6 @@ const engineLogger = { error: (message: string) => console.error(message), } -export const RULE_NAMES = Object.keys(rules) as RuleName[] -export const ALTERNATIVES_VOITURE_NAMESPACE: RuleName = "alternatives . voiture" -export const ALTERNATIVES_RULES = RULE_NAMES.filter( - (rule) => - rule.startsWith(ALTERNATIVES_VOITURE_NAMESPACE) && - `${rule} . coûts` in rules && - `${rule} . empreinte` in rules, -) - /** * A wrapper around the {@link Engine} class to compute the available aids for the * given inputs (which are a subset of the Publicodes situation corresponding to @@ -148,9 +140,7 @@ export class CarSimulator { ...inputs, } } - this.engine.setSituation( - getSituation(this.inputs) as PublicodesSituation, - ) + this.engine.setSituation(getSituation(this.inputs)) return this } @@ -193,55 +183,51 @@ export class CarSimulator { * @note This method is an expensive operation. */ public evaluateAlternatives(): Alternative[] { - const infos = ALTERNATIVES_RULES.map((rule: RuleName) => { - const splittedRule = rule.split(" . ").slice(2) - const sizeOption = splittedRule[0] as Questions["voiture . gabarit"] - const motorisationOption = - splittedRule[1] as Questions["voiture . motorisation"] - const fuelOption = ( - motorisationOption !== "électrique" ? splittedRule[2] : undefined - ) as Questions["voiture . thermique . carburant"] + const localEngine = this.getEngine().shallowCopy() + const localSituation = localEngine.getSituation() + const carSizes = this.getEngine().getPossibilitiesFor("voiture . gabarit")! + const carMotorisations = this.getEngine().getPossibilitiesFor( + "voiture . motorisation", + )! + const carFuels = this.getEngine().getPossibilitiesFor( + "voiture . thermique . carburant", + )! - if (!sizeOption || !motorisationOption) { - throw new Error( - `Invalid alternative rule ${rule}. It should have a size and a motorisation option.`, - ) - } + const res = [] - return { - kind: "car", - title: this.engine.getRule(rule).title, - cost: this.evaluateRule(ruleName(rule, "coûts")), - emissions: this.evaluateRule(ruleName(rule, "empreinte")), - size: { - value: sizeOption, - title: this.engine.getRule(ruleName("voiture . gabarit", sizeOption)) - .title, - isEnumValue: true, - isApplicable: true, - }, - motorisation: { - value: motorisationOption, - title: this.engine.getRule( - ruleName("voiture . motorisation", motorisationOption), - ).title, - isEnumValue: true, - isApplicable: true, - }, - fuel: fuelOption - ? { - value: fuelOption, - title: this.engine.getRule( - ruleName("voiture . thermique . carburant", fuelOption), - ).title, - isEnumValue: true, - isApplicable: true, - } - : undefined, - } as Alternative - }) + // NOTE: we want to use default values for the alternatives as they are + // specific for each alternative. + delete localSituation["voiture . prix d'achat"] + delete localSituation["voiture . électrique . consommation électricité"] + delete localSituation["voiture . thermique . consommation carburant"] + for (const size of carSizes) { + localSituation["voiture . gabarit"] = + size.publicodesValue as Situation["voiture . gabarit"] + for (const motorisation of carMotorisations) { + localSituation["voiture . motorisation"] = + motorisation.publicodesValue as Situation["voiture . motorisation"] + if (motorisation.nodeValue === "électrique") { + localEngine.setSituation( + localSituation as PublicodesSituation, + ) + + res.push(getAlternative(localEngine, size, motorisation, undefined)) + } else { + for (const fuel of carFuels) { + localSituation["voiture . thermique . carburant"] = + fuel.publicodesValue + + console.log(" localSituation:", localSituation) - return infos + localEngine.setSituation(localSituation) + + res.push(getAlternative(localEngine, size, motorisation, fuel)) + } + } + } + } + + return res } /** @@ -333,7 +319,7 @@ export class CarSimulator { } } -function getSituation(inputs: Questions): Situation { +function getSituation(inputs: Questions): PublicodesSituation { return Object.fromEntries( Object.entries(inputs) .filter(([, value]) => value !== undefined) @@ -350,6 +336,48 @@ function getSituation(inputs: Questions): Situation { ) } -function ruleName(namespace: RuleName, rule: string): RuleName { - return (namespace + " . " + rule) as RuleName +function getAlternative( + engine: Engine, + size: Possibility, + motorisation: Possibility, + fuel?: Possibility, +): Alternative { + return { + kind: "car", + title: `${size.title} ${motorisation.title}${fuel ? ` (${fuel.title})` : ""}`, + cost: { + title: "Coûts annuels", + unit: "€/an", + isEnumValue: false, + isApplicable: true, + value: engine.evaluate("coûts").nodeValue, + }, + emissions: { + title: "Empreinte CO2e", + unit: "kgCO2e/an", + isEnumValue: false, + isApplicable: true, + value: engine.evaluate("empreinte").nodeValue, + }, + size: { + value: size.nodeValue, + title: size.title, + isEnumValue: true, + isApplicable: true, + }, + motorisation: { + value: motorisation.nodeValue, + title: motorisation.title, + isEnumValue: true, + isApplicable: true, + }, + fuel: fuel + ? { + value: fuel.nodeValue, + title: fuel.title, + isEnumValue: true, + isApplicable: true, + } + : undefined, + } as Alternative } diff --git a/test/CarSimulator.test.ts b/test/CarSimulator.test.ts index d4e3238..153cc4b 100644 --- a/test/CarSimulator.test.ts +++ b/test/CarSimulator.test.ts @@ -275,9 +275,11 @@ describe("CarSimulator", () => { describe("evaluateAlternatives()", () => { test("should return all possible alternatives with default values", () => { const engine = globalTestEngine.shallowCopy() + engine.setInputs({ "usage . km annuels . renseignés": 1000000 }) console.time("evaluateAlternatives") const alternatives = engine.evaluateAlternatives() console.timeEnd("evaluateAlternatives") + // TODO: use engine.getOptions const nbMotorisations = 3 const nbFuels = 4 @@ -305,6 +307,96 @@ describe("CarSimulator", () => { } }) }) + + test("increasing the annual distance should increase the cost and emissions", () => { + const engine = globalTestEngine.shallowCopy() + engine.setInputs({ "usage . km annuels . renseignés": 10 }) + const alternatives = engine.evaluateAlternatives() + + engine.setInputs({ "usage . km annuels . renseignés": 10000 }) + const newAlternatives = engine.evaluateAlternatives() + + expect(alternatives).toHaveLength(newAlternatives.length) + alternatives.forEach((alternative, i) => { + expect(alternative.cost.value).toBeLessThan( + newAlternatives[i].cost.value!, + ) + expect(alternative.emissions.value).toBeLessThan( + newAlternatives[i].emissions.value!, + ) + }) + }) + + test("set km to 0 should return 0 for emissions", () => { + const engine = globalTestEngine.shallowCopy() + engine.setInputs({ + "usage . km annuels . renseignés": 0, + "usage . km annuels . connus": true, + }) + const alternatives = engine.evaluateAlternatives() + + alternatives.forEach((alternative) => { + expect(alternative.emissions.value).toEqual(0) + }) + }) + + test("modify the consumption shouldn't modify the cost and emissions", () => { + const engine = globalTestEngine.shallowCopy() + const alternatives = engine.evaluateAlternatives() + + engine.setInputs({ + "voiture . électrique . consommation électricité": 4, + "voiture . thermique . consommation carburant": 10, + }) + const newAlternatives = engine.evaluateAlternatives() + + expect(alternatives).toHaveLength(newAlternatives.length) + alternatives.forEach((alternative, i) => { + expect(alternative.cost.value).toEqual(newAlternatives[i].cost.value) + expect(alternative.emissions.value).toEqual( + newAlternatives[i].emissions.value, + ) + }) + }) + + test("modify the fuel price should modify the cost", () => { + const engine = globalTestEngine.shallowCopy() + const alternatives = engine.evaluateAlternatives() + + engine.setInputs({ + "voiture . thermique . prix carburant": 20, + "voiture . électrique . prix kWh": 20, + }) + const newAlternatives = engine.evaluateAlternatives() + + expect(alternatives).toHaveLength(newAlternatives.length) + alternatives.forEach((alternative, i) => { + expect(alternative.cost.value).toBeLessThan( + newAlternatives[i].cost.value!, + ) + expect(alternative.emissions.value).toEqual( + newAlternatives[i].emissions.value, + ) + }) + }) + + test("modify the car price shouldn't modify the cost", () => { + const engine = globalTestEngine.shallowCopy() + const alternatives = engine.evaluateAlternatives() + + engine.setInputs({ + "voiture . prix d'achat": 20000, + }) + const newAlternatives = engine.evaluateAlternatives() + + expect(alternatives).toHaveLength(newAlternatives.length) + alternatives.forEach((alternative, i) => { + expect(alternative.cost.value).toEqual(newAlternatives[i].cost.value) + expect(alternative.emissions.value).toEqual( + newAlternatives[i].emissions.value, + ) + }) + }) }) describe("evaluateTargetCar()", () => {