diff --git a/README.md b/README.md index f4dbf49..6c0e575 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # The Predictive Maintenance Game -![immagine](https://github.com/linomp/pdm-game/assets/40581019/fe7fbee0-bf31-487b-a727-f34472d94840) - +![immagine](./mvp/media/13_07_2024_trim.PNG) - **_Your mission_**: maximize profit & machine lifespan. - **_Your tools_**: data, intuition & nerve! - **_Your opponents_**: machine degradation & limited funds! - ### **_Will you accept the challenge?_** https://app.pdmgame.xmp.systems/ @@ -15,11 +13,12 @@ https://app.pdmgame.xmp.systems/ --- ### Dev roadmap + - [X] Basic UI - [X] Basic machine degradation model - [X] Sensor & prediction model purchase - [X] Live machine parameters visualization - [X] Leaderboard - [X] Basic RUL prediction model -- [ ] In-game events (e.g. production peak, score multipliers) +- [X] In-game events (e.g. production peak, score multipliers) diff --git a/mvp/client/ui/src/api/generated/models/GameSessionDTO.ts b/mvp/client/ui/src/api/generated/models/GameSessionDTO.ts index d36224b..1666f88 100644 --- a/mvp/client/ui/src/api/generated/models/GameSessionDTO.ts +++ b/mvp/client/ui/src/api/generated/models/GameSessionDTO.ts @@ -15,4 +15,5 @@ export type GameSessionDTO = { game_over_reason?: (string | null); final_score?: (number | null); user_messages?: Record; + cash_multiplier?: number; }; diff --git a/mvp/client/ui/src/api/generated/models/UserMessage.ts b/mvp/client/ui/src/api/generated/models/UserMessage.ts index f010fcd..4dcfdf2 100644 --- a/mvp/client/ui/src/api/generated/models/UserMessage.ts +++ b/mvp/client/ui/src/api/generated/models/UserMessage.ts @@ -6,7 +6,6 @@ export type UserMessage = { type: UserMessage.type; content: string; - seen?: boolean; }; export namespace UserMessage { diff --git a/mvp/client/ui/src/components/MachineData.svelte b/mvp/client/ui/src/components/MachineData.svelte index 74704f1..e6b4476 100644 --- a/mvp/client/ui/src/components/MachineData.svelte +++ b/mvp/client/ui/src/components/MachineData.svelte @@ -6,10 +6,12 @@ gameOver, gameSession, globalSettings, + isOnNarrowScreen, predictionPurchaseButtonDisabled, sensorPurchaseButtonDisabled, } from "src/stores/stores"; import Sensor from "src/components/Sensor.svelte"; + import UserMessages from "src/components/UserMessages.svelte"; export let updateGameSession: (newGameSessionDto: GameSessionDTO) => void; @@ -73,6 +75,11 @@ {#if isNotUndefinedNorNull($gameSession)}
+ {#if $isOnNarrowScreen} + {#key 'narrow'} + + {/key} + {/if}
{#each Object.entries($gameSession?.machine_state?.operational_parameters ?? {}) as [parameter, value]}
- {"Remaining Useful Life"}: + {"Remaining Useful Life 🔮"}: {$gameSession?.machine_state?.predicted_rul ? `${$gameSession.machine_state?.predicted_rul} steps` : "???"} @@ -103,23 +110,22 @@
-
- {#each Object.entries($gameSession?.user_messages ?? {}) as [key, message]} -
- {message.content} -
- {/each} -
+ {#if !$isOnNarrowScreen} + {#key 'wide'} + + {/key} + {/if}
{/if} diff --git a/mvp/client/ui/src/components/SessionData.svelte b/mvp/client/ui/src/components/SessionData.svelte index 1cfaec4..aa7825f 100644 --- a/mvp/client/ui/src/components/SessionData.svelte +++ b/mvp/client/ui/src/components/SessionData.svelte @@ -1,6 +1,6 @@ + +
+ {#each Object.entries(messages) as [key, message]} +
+ {message.content} +
+ {/each} +
+ + diff --git a/mvp/client/ui/src/pages/HomePage.svelte b/mvp/client/ui/src/pages/HomePage.svelte index 6abdb85..77b3bfd 100644 --- a/mvp/client/ui/src/pages/HomePage.svelte +++ b/mvp/client/ui/src/pages/HomePage.svelte @@ -115,7 +115,6 @@ .game-area { display: flex; flex-direction: row; - align-items: center; justify-content: center; gap: 2em; flex-wrap: wrap; diff --git a/mvp/client/ui/vite.config.mjs.timestamp-1720871382103-708d65f7c40d4.mjs b/mvp/client/ui/vite.config.mjs.timestamp-1720871382103-708d65f7c40d4.mjs new file mode 100644 index 0000000..c487c76 --- /dev/null +++ b/mvp/client/ui/vite.config.mjs.timestamp-1720871382103-708d65f7c40d4.mjs @@ -0,0 +1,54 @@ +// vite.config.mjs +import { defineConfig } from "file:///C:/Users/LM2P/Documents/000_programming/000-projects/pdm-game/mvp/client/ui/node_modules/vite/dist/node/index.js"; +import { svelte } from "file:///C:/Users/LM2P/Documents/000_programming/000-projects/pdm-game/mvp/client/ui/node_modules/@sveltejs/vite-plugin-svelte/src/index.js"; +import { run } from "file:///C:/Users/LM2P/Documents/000_programming/000-projects/pdm-game/mvp/client/ui/node_modules/vite-plugin-run/dist/index.mjs"; +import * as path from "path"; +var __vite_injected_original_dirname = "C:\\Users\\LM2P\\Documents\\000_programming\\000-projects\\pdm-game\\mvp\\client\\ui"; +var isTest = process.env.NODE_ENV === "test"; +var vite_config_default = defineConfig({ + resolve: { + alias: { + src: path.resolve(__vite_injected_original_dirname, "./src") + }, + conditions: isTest ? ["browser"] : [] + }, + plugins: [ + svelte({ hot: !process.env.VITEST }), + run({ + silent: !!process.env.VITEST, + input: [ + { + name: "typecheck", + run: ["npm", "run", "check"], + pattern: ["src/**/*.ts", "src/**/*.svelte"] + } + ] + }) + ], + server: { + port: isTest ? 8678 : 5173, + proxy: isTest ? void 0 : { + "/api": { + target: "http://localhost:8000", + changeOrigin: false + } + } + }, + build: { + outDir: "build", + target: "es2020", + cssCodeSplit: false + }, + optimizeDeps: { + include: isTest ? ["@testing-library/svelte", "chai"] : void 0 + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: "src/setup-tests.ts" + } +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcubWpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiQzpcXFxcVXNlcnNcXFxcTE0yUFxcXFxEb2N1bWVudHNcXFxcMDAwX3Byb2dyYW1taW5nXFxcXDAwMC1wcm9qZWN0c1xcXFxwZG0tZ2FtZVxcXFxtdnBcXFxcY2xpZW50XFxcXHVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJDOlxcXFxVc2Vyc1xcXFxMTTJQXFxcXERvY3VtZW50c1xcXFwwMDBfcHJvZ3JhbW1pbmdcXFxcMDAwLXByb2plY3RzXFxcXHBkbS1nYW1lXFxcXG12cFxcXFxjbGllbnRcXFxcdWlcXFxcdml0ZS5jb25maWcubWpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9DOi9Vc2Vycy9MTTJQL0RvY3VtZW50cy8wMDBfcHJvZ3JhbW1pbmcvMDAwLXByb2plY3RzL3BkbS1nYW1lL212cC9jbGllbnQvdWkvdml0ZS5jb25maWcubWpzXCI7Ly8vIDxyZWZlcmVuY2UgdHlwZXM9XCJ2aXRlc3RcIiAvPlxyXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xyXG5pbXBvcnQgeyBzdmVsdGUgfSBmcm9tICdAc3ZlbHRlanMvdml0ZS1wbHVnaW4tc3ZlbHRlJ1xyXG5pbXBvcnQgeyBydW4gfSBmcm9tICd2aXRlLXBsdWdpbi1ydW4nO1xyXG5pbXBvcnQgKiBhcyBwYXRoIGZyb20gJ3BhdGgnXHJcblxyXG5jb25zdCBpc1Rlc3QgPSBwcm9jZXNzLmVudi5OT0RFX0VOViA9PT0gJ3Rlc3QnXHJcblxyXG4vLyBodHRwczovL3ZpdGVqcy5kZXYvY29uZmlnL1xyXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xyXG4gIHJlc29sdmU6IHtcclxuICAgIGFsaWFzOiB7XHJcbiAgICAgIHNyYzogcGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgJy4vc3JjJylcclxuICAgIH0sXHJcbiAgICBjb25kaXRpb25zOiBpc1Rlc3QgPyBbJ2Jyb3dzZXInXSA6IFtdXHJcbiAgfSxcclxuICBwbHVnaW5zOiBbXHJcbiAgICBzdmVsdGUoeyBob3Q6ICFwcm9jZXNzLmVudi5WSVRFU1QgfSksXHJcbiAgICBydW4oe1xyXG4gICAgICBzaWxlbnQ6ICEhcHJvY2Vzcy5lbnYuVklURVNULFxyXG4gICAgICBpbnB1dDogW1xyXG4gICAgICAgIHtcclxuICAgICAgICAgIG5hbWU6ICd0eXBlY2hlY2snLFxyXG4gICAgICAgICAgcnVuOiBbJ25wbScsICdydW4nLCAnY2hlY2snXSxcclxuICAgICAgICAgIHBhdHRlcm46IFsnc3JjLyoqLyoudHMnLCAnc3JjLyoqLyouc3ZlbHRlJ10sXHJcbiAgICAgICAgfVxyXG4gICAgICBdXHJcbiAgICB9KVxyXG4gIF0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiBpc1Rlc3QgPyA4Njc4IDogNTE3MyxcclxuICAgIHByb3h5OiBpc1Rlc3QgPyB1bmRlZmluZWQgOiB7XHJcbiAgICAgICcvYXBpJzoge1xyXG4gICAgICAgIHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODAwMCcsXHJcbiAgICAgICAgY2hhbmdlT3JpZ2luOiBmYWxzZVxyXG4gICAgICB9XHJcbiAgICB9XHJcbiAgfSxcclxuICBidWlsZDoge1xyXG4gICAgb3V0RGlyOiAnYnVpbGQnLFxyXG4gICAgdGFyZ2V0OiAnZXMyMDIwJyxcclxuICAgIGNzc0NvZGVTcGxpdDogZmFsc2VcclxuICB9LFxyXG4gIG9wdGltaXplRGVwczoge1xyXG4gICAgaW5jbHVkZTogaXNUZXN0ID8gWydAdGVzdGluZy1saWJyYXJ5L3N2ZWx0ZScsICdjaGFpJ10gOiB1bmRlZmluZWRcclxuICB9LFxyXG4gIHRlc3Q6IHtcclxuICAgIGdsb2JhbHM6IHRydWUsXHJcbiAgICBlbnZpcm9ubWVudDogJ2pzZG9tJyxcclxuICAgIHNldHVwRmlsZXM6ICdzcmMvc2V0dXAtdGVzdHMudHMnXHJcbiAgfVxyXG59KVxyXG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQ0EsU0FBUyxvQkFBb0I7QUFDN0IsU0FBUyxjQUFjO0FBQ3ZCLFNBQVMsV0FBVztBQUNwQixZQUFZLFVBQVU7QUFKdEIsSUFBTSxtQ0FBbUM7QUFNekMsSUFBTSxTQUFTLFFBQVEsSUFBSSxhQUFhO0FBR3hDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLEtBQVUsYUFBUSxrQ0FBVyxPQUFPO0FBQUEsSUFDdEM7QUFBQSxJQUNBLFlBQVksU0FBUyxDQUFDLFNBQVMsSUFBSSxDQUFDO0FBQUEsRUFDdEM7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNQLE9BQU8sRUFBRSxLQUFLLENBQUMsUUFBUSxJQUFJLE9BQU8sQ0FBQztBQUFBLElBQ25DLElBQUk7QUFBQSxNQUNGLFFBQVEsQ0FBQyxDQUFDLFFBQVEsSUFBSTtBQUFBLE1BQ3RCLE9BQU87QUFBQSxRQUNMO0FBQUEsVUFDRSxNQUFNO0FBQUEsVUFDTixLQUFLLENBQUMsT0FBTyxPQUFPLE9BQU87QUFBQSxVQUMzQixTQUFTLENBQUMsZUFBZSxpQkFBaUI7QUFBQSxRQUM1QztBQUFBLE1BQ0Y7QUFBQSxJQUNGLENBQUM7QUFBQSxFQUNIO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixNQUFNLFNBQVMsT0FBTztBQUFBLElBQ3RCLE9BQU8sU0FBUyxTQUFZO0FBQUEsTUFDMUIsUUFBUTtBQUFBLFFBQ04sUUFBUTtBQUFBLFFBQ1IsY0FBYztBQUFBLE1BQ2hCO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxJQUNSLFFBQVE7QUFBQSxJQUNSLGNBQWM7QUFBQSxFQUNoQjtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ1osU0FBUyxTQUFTLENBQUMsMkJBQTJCLE1BQU0sSUFBSTtBQUFBLEVBQzFEO0FBQUEsRUFDQSxNQUFNO0FBQUEsSUFDSixTQUFTO0FBQUEsSUFDVCxhQUFhO0FBQUEsSUFDYixZQUFZO0FBQUEsRUFDZDtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg== diff --git a/mvp/media/13_07_2024.PNG b/mvp/media/13_07_2024.PNG new file mode 100644 index 0000000..e584f1a Binary files /dev/null and b/mvp/media/13_07_2024.PNG differ diff --git a/mvp/media/13_07_2024_trim.PNG b/mvp/media/13_07_2024_trim.PNG new file mode 100644 index 0000000..a373edc Binary files /dev/null and b/mvp/media/13_07_2024_trim.PNG differ diff --git a/mvp/server/core/analysis/rul_prediction.py b/mvp/server/core/analysis/rul_prediction.py index 616ee18..9b5d72e 100644 --- a/mvp/server/core/analysis/rul_prediction.py +++ b/mvp/server/core/analysis/rul_prediction.py @@ -4,7 +4,7 @@ import numpy as np import onnxruntime as rt -from mvp.server.core.machine.OperationalParameters import OperationalParameters +from mvp.server.core.machine.MachineState import OperationalParameters # Load the SVR pipeline from ONNX file onnx_path = "mvp/server/core/analysis/artifacts/svr_pipeline_23_06_24.onnx" diff --git a/mvp/server/core/constants.py b/mvp/server/core/constants.py index 85b4a1e..85a0ecd 100644 --- a/mvp/server/core/constants.py +++ b/mvp/server/core/constants.py @@ -1,6 +1,6 @@ # Gameplay DEFAULT_SESSION_ID = 'test' -GAME_TICK_INTERVAL = 0.03 # 30ms +GAME_TICK_INTERVAL = 0.015 # 30ms IDLE_SESSION_TTL_SECONDS = 60 * 30 # 15 minutes SESSION_CLEANUP_INTERVAL_SECONDS = 60 * 60 # 60 minutes TIMESTEPS_PER_MOVE = 24 # "hours" @@ -22,7 +22,9 @@ # Financials INITIAL_CASH = 0 -REVENUE_PER_DAY = 20 +REVENUE_PER_DAY = 15 MAINTENANCE_COST = 40 SENSOR_COST = 30 -PREDICTION_MODEL_COST = 50 +PREDICTION_MODEL_COST = 80 +DEMAND_PEAK_EVENT_PROBABILITY = 0.20 +DEMAND_PEAK_BONUS_MULTIPLIER = 3 diff --git a/mvp/server/core/game/GameSession.py b/mvp/server/core/game/GameSession.py index 3302db9..36c3c69 100644 --- a/mvp/server/core/game/GameSession.py +++ b/mvp/server/core/game/GameSession.py @@ -1,5 +1,6 @@ import asyncio import os +import random from datetime import datetime from typing import Callable @@ -9,8 +10,7 @@ from mvp.server.core.analysis.rul_prediction import default_rul_prediction_fn, svr_rul_prediction_fn from mvp.server.core.constants import * from mvp.server.core.game.UserMessage import UserMessage -from mvp.server.core.machine.MachineState import MachineState -from mvp.server.core.machine.OperationalParameters import OperationalParameters +from mvp.server.core.machine.MachineState import MachineState, OperationalParameters load_dotenv() @@ -30,6 +30,7 @@ class GameSession(BaseModel): state_publish_function: Callable[["GameSession"], None] rul_predictor: Callable[[int, OperationalParameters, list[str]], int | None] = default_rul_prediction_fn user_messages: dict[str, UserMessage] = {} + cash_multiplier: int = 1 @staticmethod def new_game_session(_id: str, _state_publish_function: Callable[["GameSession"], None]) -> "GameSession": @@ -74,25 +75,31 @@ def update_game_over_flag(self) -> None: f"{datetime.now()}: GameSession '{self.id}' - machine failed at step {self.current_step} - {self.machine_state}" ) - async def advance_one_turn(self) -> list[MachineState]: + async def advance_one_turn(self) -> list[MachineState] | None: collected_machine_states_during_turn = [] self.last_updated = datetime.now() + # if there is a demand peak bonus up to this point, multiply the player cash for this turn! + if "demand_peak_bonus" in self.user_messages: + self.cash_multiplier = DEMAND_PEAK_BONUS_MULTIPLIER + + self.user_messages.pop("demand_peak_bonus", None) + for _ in range(TIMESTEPS_PER_MOVE): - # collect stats - collected_machine_states_during_turn.append(self.machine_state) + + if os.getenv("COLLECT_MACHINE_HISTORY", False): + collected_machine_states_during_turn.append(self.machine_state) self.update_game_over_flag() if self.is_game_over: - break + return self.current_step += 1 self.machine_state.update_parameters(self.current_step) - # Player earns money for the production at every timestep, - # proportional to the health of the machine (bad health = less efficient production) - self.available_funds += (self.machine_state.health_percentage / 50) * REVENUE_PER_DAY / TIMESTEPS_PER_MOVE + # Player earns money for the production at every timestep + self.available_funds += self.cash_multiplier * REVENUE_PER_DAY / TIMESTEPS_PER_MOVE # Publish state every 2 steps (to reduce the load on the MQTT broker) if self.current_step % 2 == 0: @@ -101,20 +108,37 @@ async def advance_one_turn(self) -> list[MachineState]: await asyncio.sleep(GAME_TICK_INTERVAL) self.update_rul_prediction() + self.cash_multiplier = 1 + + # 😈 probability of bonus multiplier increases with time, when it is also most risky for the player to skip maintenance! + # TODO: organize this better, there are too many things mixed here... probably won't remember what this code does in 1 week! + if ((random.random() / min(1, self.current_step)) < DEMAND_PEAK_EVENT_PROBABILITY) or os.getenv( + "DEV_FORCE_DEMAND_PEAK_EVENT", False): + self.user_messages["demand_peak_bonus"] = UserMessage( + type="INFO", + content=f"Demand Peak! - Skip maintenance and earn {DEMAND_PEAK_BONUS_MULTIPLIER}x cash in the next turn!" + ) - self.machine_state_history.extend( - zip( - range(self.current_step - TIMESTEPS_PER_MOVE, self.current_step), - collected_machine_states_during_turn + if os.getenv("COLLECT_MACHINE_HISTORY", False): + self.machine_state_history.extend( + zip( + range(self.current_step - TIMESTEPS_PER_MOVE, self.current_step), + collected_machine_states_during_turn + ) ) - ) + return collected_machine_states_during_turn - return collected_machine_states_during_turn + # TODO: fix this horrible, horrible hack; we need the timestep of the POST request response to be higher than those from the intermediate states + # but having to move it till the end of the function just so that the test passes (matching the expected timesteps per move, before this extra one added) is absolutely painful! + self.current_step += 1 def do_maintenance(self) -> bool: if self.available_funds < MAINTENANCE_COST: return False + # if there was a demand peak bonus and player does maintenance, it gets cleared + self.user_messages.pop("demand_peak_bonus", None) + self.current_step += 1 self.available_funds -= MAINTENANCE_COST self.machine_state.do_maintenance() @@ -146,7 +170,7 @@ def purchase_prediction(self, prediction: str) -> bool: return True def update_rul_prediction(self) -> None: - self.user_messages.clear() + self.user_messages.pop("rul_accuracy_warning", None) # TODO: clean up this stuff; it feels awkward having to iterate when there is only 1 type of prediction... for prediction, purchased in self.available_predictions.items(): diff --git a/mvp/server/core/game/GameSessionDTO.py b/mvp/server/core/game/GameSessionDTO.py index f949501..e2a9ecf 100644 --- a/mvp/server/core/game/GameSessionDTO.py +++ b/mvp/server/core/game/GameSessionDTO.py @@ -17,6 +17,7 @@ class GameSessionDTO(BaseModel): game_over_reason: str | None = None final_score: float | None = None user_messages: dict[str, UserMessage] = {} + cash_multiplier: int = 1 @staticmethod def from_session(session: GameSession) -> "GameSessionDTO": @@ -27,7 +28,8 @@ def from_session(session: GameSession) -> "GameSessionDTO": is_game_over=session.is_game_over, machine_state=MachineStateDTO.from_machine_state(session.machine_state), final_score=None, - user_messages=session.user_messages + user_messages=session.user_messages, + cash_multiplier=session.cash_multiplier ) if session.is_game_over: @@ -55,5 +57,6 @@ def from_dict(json: dict[str, Any]) -> "GameSessionDTO": machine_state=MachineStateDTO.from_dict(json.get("machine_state", {})), available_funds=json.get("available_funds", 0.), is_game_over=json.get("is_game_over", False), - user_messages=json.get("user_messages", {}) + user_messages=json.get("user_messages", {}), + cash_multiplier=json.get("current_step", 1), ) diff --git a/mvp/server/core/game/UserMessage.py b/mvp/server/core/game/UserMessage.py index 9a3505e..b3d4c01 100644 --- a/mvp/server/core/game/UserMessage.py +++ b/mvp/server/core/game/UserMessage.py @@ -6,4 +6,3 @@ class UserMessage(BaseModel): type: Literal["WARNING", "INFO"] content: str - seen: bool = False diff --git a/mvp/server/core/machine/MachineState.py b/mvp/server/core/machine/MachineState.py index 5513f80..c2ec814 100644 --- a/mvp/server/core/machine/MachineState.py +++ b/mvp/server/core/machine/MachineState.py @@ -1,11 +1,117 @@ +import math +import random from typing import Any, Callable from pydantic import BaseModel -from mvp.server.core.constants import TEMPERATURE_STARTING_POINT, HEALTH_RECOVERY_FACTOR_ON_MAINTENANCE, \ - MECHANICAL_WEAR_REDUCTION_FACTOR_ON_MAINTENANCE -from mvp.server.core.machine.OperationalParameters import OperationalParameters -from mvp.server.core.math_utils import constrain_from_0_to_100 +from mvp.server.core.constants import * +from mvp.server.core.math_utils import linear_growth_with_reset, map_value, exponential_decay, constrain_from_0_to_100 + + +class OperationalParameters(BaseModel): + temperature: float | None + oil_age: float | None + mechanical_wear: float | None + + def get_purchasable_sensors(self) -> set[str]: + return self.model_fields_set + + def update(self, current_timestep: int) -> None: + self.temperature = self.compute_machine_temperature(current_timestep) + self.oil_age = self.compute_oil_age(current_timestep) + self.mechanical_wear = self.compute_mechanical_wear(current_timestep) + + def compute_health_percentage(self, current_timestep: int, current_health: float) -> float: + raw_value = round( + exponential_decay( + current_timestep, + initial_value=current_health, + decay_speed=self.compute_decay_speed() + ) + ) + + raw_value -= random.random() * (0.005 * raw_value) + + return constrain_from_0_to_100(raw_value) + + def compute_decay_speed(self) -> float: + # TODO: calibrate these weights + temperature_weight = 0.01 + oil_age_weight = 0.001 + mechanical_wear_weight = 0.1 + + # Made-up calculation involving operational parameters: temperature, oil age, mechanical wear + computed = self.temperature * temperature_weight + \ + self.oil_age * oil_age_weight + \ + self.mechanical_wear * mechanical_wear_weight + + mapping_max = TEMPERATURE_MAPPING_MAX * temperature_weight + \ + OIL_AGE_MAPPING_MAX * oil_age_weight + \ + MECHANICAL_WEAR_MAPPING_MAX * mechanical_wear_weight + + computed = min(mapping_max, computed) + + return map_value(computed, from_low=0, from_high=mapping_max, to_low=0, to_high=0.005) + + def compute_machine_temperature(self, current_timestep: int) -> float: + # temperature grows linearly over the 8 hours of a shift (resets every 8 hours) + raw_value = self.mechanical_wear * linear_growth_with_reset( + initial_value=0, + period=TIMESTEPS_PER_MOVE, + current_timestep=current_timestep + ) + + raw_value -= random.random() * raw_value + + return map_value( + raw_value, + from_low=0, + from_high=TIMESTEPS_PER_MOVE - 1, + to_low=TEMPERATURE_STARTING_POINT, + to_high=TEMPERATURE_MAPPING_MAX + ) + + def compute_oil_age(self, current_timestep: int) -> float: + # oil age grows monotonically and resets only after every maintenance routine + raw_value = min(1e6, self.oil_age + ((current_timestep / 1000) * (self.temperature ** 2))) + raw_value += random.random() * raw_value + + return map_value( + raw_value, + from_low=self.oil_age, + from_high=1e6, + to_low=self.oil_age, + to_high=OIL_AGE_MAPPING_MAX + ) + + def compute_mechanical_wear(self, current_timestep: int) -> float: + # mechanical wear grows monotonically, directly proportional to oil ag. + # for now it never resets (such that at some point, the machine will definitely break and game over) + raw_value = min(1e6, math.exp(current_timestep / 200) * self.oil_age) + raw_value += random.random() * raw_value + + return map_value( + raw_value, + from_low=0, + from_high=1e6, + to_low=self.mechanical_wear, + to_high=MECHANICAL_WEAR_MAPPING_MAX + ) + + def to_dict(self) -> dict[str, float]: + return { + "temperature": self.temperature, + "oil_age": self.oil_age, + "mechanical_wear": self.mechanical_wear + } + + @staticmethod + def from_dict(json: dict[str, float]) -> 'OperationalParameters': + return OperationalParameters( + temperature=json.get("temperature", 0), + oil_age=json.get("oil_age", 0), + mechanical_wear=json.get("mechanical_wear", 0) + ) class MachineState(BaseModel): diff --git a/mvp/server/core/machine/MachineStateDTO.py b/mvp/server/core/machine/MachineStateDTO.py index 36dbcb9..cf7a7c4 100644 --- a/mvp/server/core/machine/MachineStateDTO.py +++ b/mvp/server/core/machine/MachineStateDTO.py @@ -2,8 +2,7 @@ from pydantic import BaseModel -from mvp.server.core.machine import MachineState -from mvp.server.core.machine.OperationalParameters import OperationalParameters +from mvp.server.core.machine.MachineState import MachineState, OperationalParameters class MachineStateDTO(BaseModel): diff --git a/mvp/server/core/machine/OperationalParameters.py b/mvp/server/core/machine/OperationalParameters.py deleted file mode 100644 index afa4300..0000000 --- a/mvp/server/core/machine/OperationalParameters.py +++ /dev/null @@ -1,113 +0,0 @@ -import math -import random - -from pydantic import BaseModel - -from mvp.server.core.constants import * -from mvp.server.core.math_utils import linear_growth_with_reset, map_value, exponential_decay, constrain_from_0_to_100 - - -class OperationalParameters(BaseModel): - temperature: float | None - oil_age: float | None - mechanical_wear: float | None - - def get_purchasable_sensors(self) -> set[str]: - return self.model_fields_set - - def update(self, current_timestep: int) -> None: - self.temperature = self.compute_machine_temperature(current_timestep) - self.oil_age = self.compute_oil_age(current_timestep) - self.mechanical_wear = self.compute_mechanical_wear(current_timestep) - - def compute_health_percentage(self, current_timestep: int, current_health: float) -> float: - raw_value = round( - exponential_decay( - current_timestep, - initial_value=current_health, - decay_speed=self.compute_decay_speed() - ) - ) - - raw_value -= random.random() * (0.005 * raw_value) - - return constrain_from_0_to_100(raw_value) - - def compute_decay_speed(self) -> float: - # TODO: calibrate these weights - temperature_weight = 0.01 - oil_age_weight = 0.001 - mechanical_wear_weight = 0.1 - - # Made-up calculation involving operational parameters: temperature, oil age, mechanical wear - computed = self.temperature * temperature_weight + \ - self.oil_age * oil_age_weight + \ - self.mechanical_wear * mechanical_wear_weight - - mapping_max = TEMPERATURE_MAPPING_MAX * temperature_weight + \ - OIL_AGE_MAPPING_MAX * oil_age_weight + \ - MECHANICAL_WEAR_MAPPING_MAX * mechanical_wear_weight - - computed = min(mapping_max, computed) - - return map_value(computed, from_low=0, from_high=mapping_max, to_low=0, to_high=0.005) - - def compute_machine_temperature(self, current_timestep: int) -> float: - # temperature grows linearly over the 8 hours of a shift (resets every 8 hours) - raw_value = self.mechanical_wear * linear_growth_with_reset( - initial_value=0, - period=TIMESTEPS_PER_MOVE, - current_timestep=current_timestep - ) - - raw_value -= random.random() * raw_value - - return map_value( - raw_value, - from_low=0, - from_high=TIMESTEPS_PER_MOVE - 1, - to_low=TEMPERATURE_STARTING_POINT, - to_high=TEMPERATURE_MAPPING_MAX - ) - - def compute_oil_age(self, current_timestep: int) -> float: - # oil age grows monotonically and resets only after every maintenance routine - raw_value = min(1e6, self.oil_age + ((current_timestep / 1000) * (self.temperature ** 2))) - raw_value += random.random() * raw_value - - return map_value( - raw_value, - from_low=self.oil_age, - from_high=1e6, - to_low=self.oil_age, - to_high=OIL_AGE_MAPPING_MAX - ) - - def compute_mechanical_wear(self, current_timestep: int) -> float: - # mechanical wear grows monotonically, directly proportional to oil ag. - # for now it never resets (such that at some point, the machine will definitely break and game over) - raw_value = min(1e6, math.exp(current_timestep / 200) * self.oil_age) - raw_value += random.random() * raw_value - - return map_value( - raw_value, - from_low=0, - from_high=1e6, - to_low=self.mechanical_wear, - to_high=MECHANICAL_WEAR_MAPPING_MAX - ) - - def to_dict(self) -> dict[str, float]: - return { - "temperature": self.temperature, - "oil_age": self.oil_age, - "mechanical_wear": self.mechanical_wear - } - - @staticmethod - def from_dict(json: dict[str, float]) -> 'OperationalParameters': - return OperationalParameters( - temperature=json.get("temperature", 0), - oil_age=json.get("oil_age", 0), - mechanical_wear=json.get("mechanical_wear", 0) - ) diff --git a/mvp/server/tests/test_game_session.py b/mvp/server/tests/test_game_session.py index dc6f68a..346db81 100644 --- a/mvp/server/tests/test_game_session.py +++ b/mvp/server/tests/test_game_session.py @@ -1,3 +1,4 @@ +import os from unittest.mock import MagicMock import pytest @@ -24,6 +25,8 @@ def test_game_session_initialization(game_session): @pytest.mark.asyncio async def test_game_session_advance_one_turn(game_session): + os.environ["COLLECT_MACHINE_HISTORY"] = "1" + initial_health = game_session.machine_state.health_percentage initial_step = game_session.current_step diff --git a/mvp/server/tests/test_operational_parameters.py b/mvp/server/tests/test_operational_parameters.py index 582b229..8e05461 100644 --- a/mvp/server/tests/test_operational_parameters.py +++ b/mvp/server/tests/test_operational_parameters.py @@ -1,5 +1,5 @@ from mvp.server.core.constants import TIMESTEPS_PER_MOVE -from mvp.server.core.machine.OperationalParameters import OperationalParameters +from mvp.server.core.machine.MachineState import OperationalParameters def test_temperature_grows_monotonically(): diff --git a/mvp/server/tests/test_rul_prediction.py b/mvp/server/tests/test_rul_prediction.py index 98bcd42..7bd00fe 100644 --- a/mvp/server/tests/test_rul_prediction.py +++ b/mvp/server/tests/test_rul_prediction.py @@ -4,7 +4,7 @@ import numpy as np from mvp.server.core.analysis.rul_prediction import svr_rul_prediction_fn -from mvp.server.core.machine.OperationalParameters import OperationalParameters +from mvp.server.core.machine.MachineState import OperationalParameters class TestSVRRULPredictionFn(unittest.TestCase):