From 6745f6ad9f5af4628046d500b3dfcd6e95d7d272 Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:05:32 +0300 Subject: [PATCH 1/6] Added unit tests --- src/graphics/manager.py | 3 +- src/physics/kinetic.py | 6 ++- src/physics/universe.py | 2 +- src/simulation.py | 8 ++-- src/utils/vector.py | 7 ++- tests/test_common.py | 0 tests/test_physics.py | 99 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 8 deletions(-) delete mode 100644 tests/test_common.py create mode 100644 tests/test_physics.py diff --git a/src/graphics/manager.py b/src/graphics/manager.py index e56d4d5..313d36f 100644 --- a/src/graphics/manager.py +++ b/src/graphics/manager.py @@ -38,7 +38,8 @@ def __init__(self): self.__render_queue = [] self.__remove_queue = [] - def set_caption(self, cap : str): + @staticmethod + def set_caption(cap : str): pygame.display.set_caption(cap) def register(self, obj): diff --git a/src/physics/kinetic.py b/src/physics/kinetic.py index 895b379..db13678 100644 --- a/src/physics/kinetic.py +++ b/src/physics/kinetic.py @@ -4,6 +4,8 @@ from copy import copy import math import random + +import astroid.nodes import pygame import simulation @@ -195,11 +197,13 @@ def astro_radius(self, radius): @staticmethod def astro_to_gui_pos(astro_pos : Vector) -> Tuple[int, int]: + if astro_pos.magnitude == 0: + return (0, 0) return (astro_pos.normalized * astro_to_gui_distance(astro_pos.magnitude)).to_tuple() def try_scatter(self): if self.current_acceleration.magnitude: - if self.current_acceleration.magnitude / 3 > G_CONST * self.mass / (self.astro_radius ** 2): + if self.current_acceleration.magnitude / 30 > G_CONST * self.mass / (self.astro_radius ** 2): self.scatter() def scatter(self): diff --git a/src/physics/universe.py b/src/physics/universe.py index af92580..1c4a088 100644 --- a/src/physics/universe.py +++ b/src/physics/universe.py @@ -3,10 +3,10 @@ import math from typing import TYPE_CHECKING -from physics import kinetic from graphics import manager from utils.utility import Singleton from utils.vector import Vector +from physics import kinetic from . import universe_utils diff --git a/src/simulation.py b/src/simulation.py index b038965..63cc136 100644 --- a/src/simulation.py +++ b/src/simulation.py @@ -2,9 +2,9 @@ import typing +from graphics import manager +from physics import universe from utils.utility import Singleton -from graphics.manager import Manager -from physics.universe import Universe if typing.TYPE_CHECKING: from typing import Callable @@ -24,8 +24,8 @@ class Simulation: def __init__(self): self.__running = True - self._manager = Manager() - self._universe = Universe() + self._manager = manager.Manager() + self._universe = universe.Universe() @property def manager(self): diff --git a/src/utils/vector.py b/src/utils/vector.py index 4d9a75a..70cf86b 100644 --- a/src/utils/vector.py +++ b/src/utils/vector.py @@ -22,10 +22,15 @@ def __add__(self, other): return Vector(self.x + other.x, self.y + other.y) raise NotImplementedError(type(other)) + def __eq__(self, other): + if isinstance(other, Vector): + return self.x == other.x and self.y == other.y + raise NotImplementedError(type(other)) + def __sub__(self, other): if isinstance(other, Vector): return Vector(self.x - other.x, self.y - other.y) - raise NotImplementedError + raise NotImplementedError(type(other)) @staticmethod def vector_sum(v1 : Vector, v2 : Vector): diff --git a/tests/test_common.py b/tests/test_common.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..2b3fa57 --- /dev/null +++ b/tests/test_physics.py @@ -0,0 +1,99 @@ +import pytest + +from physics import universe, kinetic, universe_utils +from utils.utility import Singleton +from utils.vector import Vector +from graphics import manager + + +@Singleton +class FakeManager: + def __init__(self): + self.__render_queue = [] + self.__remove_queue = [] + + def register(self, obj): + if obj not in self.__render_queue: + self.__render_queue.append(obj) + + def unregister(self, obj): + if obj in self.__render_queue and obj not in self.__remove_queue: + self.__remove_queue.append(obj) + + def update(self): + pass + + @property + def screen(self): + return None + + @staticmethod + def set_caption(cap : str): + pass + + +@pytest.fixture() +def fake_manager(monkeypatch): + monkeypatch.setattr(manager.Manager, "_instance", FakeManager()) + + +@pytest.fixture() +def no_sanitize(monkeypatch): + uni = universe.Universe() + monkeypatch.setattr(uni, "_Universe__sanitize", lambda _: None) + + +def test_kinetic_tick(monkeypatch, fake_manager, no_sanitize): + dummy = kinetic.Kinetic( + 100, Vector(0, 0), (255, 255, 255), 1, 1, "Test Dummy" + ) + monkeypatch.setattr(dummy, "try_scatter", lambda: False) + + uni = universe.Universe() + assert uni.kinetic_registry.registered(dummy) + assert len(uni.kinetic_registry) == 1 + uni.tick(0.1) + + assert dummy.astro_position == Vector(0, 0) + dummy.apply_velocity(Vector(2, 0)) + uni.tick(1) + assert dummy.astro_position == Vector(2, 0) + uni.tick(1) + assert dummy.astro_position == Vector(4, 0) + + dummy.astro_position = Vector(0, 0) + dummy.apply_velocity(Vector(0, 0)) + dummy.apply_acceleration(Vector(2, 0)) + uni.tick(0.1) + assert not dummy.current_acceleration.magnitude + + dummy.apply_force(Vector(1, 0)) + assert dummy.current_acceleration.magnitude == 1 / dummy.mass + uni.tick(0.1) + assert dummy.current_acceleration.magnitude == 0 + assert dummy.current_velocity.magnitude + uni.finalize() + + +def test_physics_kinetic_scatter(monkeypatch, fake_manager, no_sanitize): + # Well, it's mostly done to sanitize the universe and prevent FPS loss + # Kinetic will scatter, when outside forces apply acceleration, + # that's 30 times bigger that it's surface acceleration of gravity + + dummy = kinetic.Kinetic( + 1e15, Vector(0, 0), (255, 255, 255), 500, 1, "Test Dummy" + ) + + uni = universe.Universe() + + registry = uni.kinetic_registry + assert len(registry) == 1 + + dummy.apply_acceleration(Vector(2, 0)) + uni.tick(1) + assert len(registry) == 1 + + dummy.apply_acceleration(Vector(dummy.mass / 250000 * universe_utils.G_CONST * 30, 0.1)) + uni.tick(1) + assert len(registry) == 4 + uni.finalize() From e1d04e121870cd9a1f0542f1726d3bba6f1216ba Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:06:22 +0300 Subject: [PATCH 2/6] Added automated testing --- .github/workflows/pylint.yml | 1 - .github/workflows/pytest.yml | 31 +++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index f1d981a..9006d92 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -29,4 +29,3 @@ jobs: - name: Analysing the code with pylint run: | pylint --init-hook="import sys; sys.path.append('.')" src - diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..4be57c1 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,31 @@ +name: Pytest + +on: + push: + branches: ["main"] + pull_request: + branches: + - main + - release + - feature/* + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + - name: Analysing the code with pylint + run: | + pytest From 40ccbc425b83c6037c583de8b229ff2984607a34 Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:22:45 +0300 Subject: [PATCH 3/6] Added unit tests --- src/physics/universe.py | 2 ++ tests/test_physics.py | 68 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/physics/universe.py b/src/physics/universe.py index 1c4a088..aa07ba6 100644 --- a/src/physics/universe.py +++ b/src/physics/universe.py @@ -45,6 +45,7 @@ def registered(self, obj): def clear(self): self.__registry.clear() + self.__remove_queue.clear() def update(self): for obj in self.__remove_queue: @@ -111,6 +112,7 @@ def try_collapse(self, k1 : Kinetic, k2 : Kinetic, delta_time : float) -> bool: return True if dist > (k1.astro_radius + k2.astro_radius) * 80: + self.__collapse_kinetics(k1, k2) return False if velocity.magnitude * delta_time * 4 > dist: diff --git a/tests/test_physics.py b/tests/test_physics.py index 2b3fa57..c996e92 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -1,3 +1,8 @@ +""" +TDD is good. Make some unit tests. Sometimes it's fun, sometimes not, but anyway TDD is awesome. +Respect others, never change unit tests without a reason (for example to bypass status checks). +""" + import pytest from physics import universe, kinetic, universe_utils @@ -97,3 +102,66 @@ def test_physics_kinetic_scatter(monkeypatch, fake_manager, no_sanitize): uni.tick(1) assert len(registry) == 4 uni.finalize() + + +@pytest.mark.parametrize( + 'initial_velocity', + [ + 101, + 250, + 500, + 1000, + 1e6 + ] +) +def test_physics_improved_impacts(monkeypatch, fake_manager, no_sanitize, initial_velocity): + uni = universe.Universe() + + dummy1 = kinetic.Kinetic( + 2, Vector(0, 0), (255, 255, 255), 5, 1, "Test Dummy 1" + ) + dummy2 = kinetic.Kinetic( + 2, Vector(100, 0), (255, 255, 255), 5, 1, "Test Dummy 2" + ) + registry = uni.kinetic_registry + assert len(registry) == 2 + + dummy1.apply_velocity(Vector(initial_velocity, 0)) + assert uni.try_collapse(dummy1, dummy2, 1) + uni.tick(0.1) + assert len(registry) == 1 + uni.finalize() + +@pytest.mark.parametrize( + 'initial_velocity', + [ + 101, + 250, + 500, + 1000, + 1e6 + ] +) +def test_physics_improved_impacts_non_direct(monkeypatch, fake_manager, no_sanitize, initial_velocity): + # Well, in fact those object won't actually collide + # That will be indirect impact, so instead they will break into fragments. + # This simulation is simplified, so we'll take it as collision and collapse two objects into one + # + # I've had a bunch of bugs with this algo, so here's the test + + uni = universe.Universe() + + dummy1 = kinetic.Kinetic( + 2, Vector(0, 5), (255, 255, 255), 5, 1, "Test Dummy 1" + ) + dummy2 = kinetic.Kinetic( + 2, Vector(100, -5), (255, 255, 255), 5, 1, "Test Dummy 2" + ) + registry = uni.kinetic_registry + assert len(registry) == 2 + + dummy1.apply_velocity(Vector(initial_velocity, 0)) + assert uni.try_collapse(dummy1, dummy2, 1) + uni.tick(0.1) + assert len(registry) == 1 + uni.finalize() From 45fc51f6fecce2f911e07cb996c4cfafa36a2280 Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:33:54 +0300 Subject: [PATCH 4/6] Fixed collisions and added global point configuration --- src/config/__init__.py | 0 src/config/config.py | 25 +++++++++++++++++++++++++ src/physics/kinetic.py | 10 +++++----- src/physics/universe.py | 1 - src/physics/universe_utils.py | 6 +++--- src/simulation.py | 7 +++---- 6 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 src/config/__init__.py create mode 100644 src/config/config.py diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..3bb604f --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,25 @@ +class Configuration: + # region SIMULATION + + TIME_UNIT = 1.0 + TIME_MULTIPLIER = 3 + + DELTA_TIME = TIME_UNIT * TIME_MULTIPLIER + + # endregion + + # region UNIVERSE + + G_CONST = 6.6743e-11 + UNIT_SIZE = 2e6 + + # endregion + + # region ASTEROIDS + + MASS = (1e9, 1.e12) + POSITION = (10, 1600, 10, 1000) + BASE_VELOCITY_MUL = 1e6 + DENSITY = 5.6e12 + + # endregion \ No newline at end of file diff --git a/src/physics/kinetic.py b/src/physics/kinetic.py index db13678..92c4fb2 100644 --- a/src/physics/kinetic.py +++ b/src/physics/kinetic.py @@ -5,7 +5,6 @@ import math import random -import astroid.nodes import pygame import simulation @@ -13,6 +12,7 @@ from astro.basics import Object from utils.vector import Vector from utils import name_generator +from config.config import Configuration from . import universe from .universe_utils import UNIT_SIZE, astro_to_gui_distance, G_CONST @@ -288,10 +288,10 @@ def tick(self, delta_time : float): class Asteroid(Kinetic): - MASS = (1e9, 1.e12) - POSITION = (10, 1600, 10, 1000) - BASE_VELOCITY_MUL = 1e6 - DENSITY = 5.6e12 + MASS = Configuration.MASS + POSITION = Configuration.POSITION + BASE_VELOCITY_MUL = Configuration.BASE_VELOCITY_MUL + DENSITY = Configuration.DENSITY @staticmethod def generate_radius(mass): diff --git a/src/physics/universe.py b/src/physics/universe.py index aa07ba6..e427060 100644 --- a/src/physics/universe.py +++ b/src/physics/universe.py @@ -112,7 +112,6 @@ def try_collapse(self, k1 : Kinetic, k2 : Kinetic, delta_time : float) -> bool: return True if dist > (k1.astro_radius + k2.astro_radius) * 80: - self.__collapse_kinetics(k1, k2) return False if velocity.magnitude * delta_time * 4 > dist: diff --git a/src/physics/universe_utils.py b/src/physics/universe_utils.py index 391d628..d3b3278 100644 --- a/src/physics/universe_utils.py +++ b/src/physics/universe_utils.py @@ -3,15 +3,15 @@ import math import typing - from utils.vector import Vector +from config.config import Configuration if typing.TYPE_CHECKING: from physics.kinetic import Kinetic -G_CONST = 6.6743e-11 # Newtonian gravity constant -UNIT_SIZE = 2e6 # Unit to meter +G_CONST = Configuration.G_CONST +UNIT_SIZE = Configuration.UNIT_SIZE def generate_v1(k1 : Kinetic, k2 : Kinetic) -> Vector: diff --git a/src/simulation.py b/src/simulation.py index 63cc136..c52c893 100644 --- a/src/simulation.py +++ b/src/simulation.py @@ -6,12 +6,11 @@ from physics import universe from utils.utility import Singleton +from config.config import Configuration + if typing.TYPE_CHECKING: from typing import Callable -TIME_UNIT = 1.0 -TIME_DELTA = TIME_UNIT * 3 - @Singleton class Simulation: @@ -42,7 +41,7 @@ def start(self, init : Callable = None): init() while self.__running: - self.tick(TIME_DELTA) + self.tick(Configuration.DELTA_TIME) self._manager.update() def tick(self, delta_time : float = 0): From 3298b5870d03b3f80e67a2db91c920ff7f3b6d5b Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:53:08 +0300 Subject: [PATCH 5/6] Updated readme --- README.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a878008..c5e1e31 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ ## Repo health [![Pylint](https://github.com/kbrddestroyer/Python.Astro/actions/workflows/pylint.yml/badge.svg?branch=main)](https://github.com/kbrddestroyer/Python.Astro/actions/workflows/pylint.yml) +[![Pytest](https://github.com/kbrddestroyer/Python.Astro/actions/workflows/pytest.yml/badge.svg?branch=main&event=push)](https://github.com/kbrddestroyer/Python.Astro/actions/workflows/pytest.yml) ## About this repo Astro is python-based mathematical simulation of Newtonian gravity between multiple physical objects in 2D space. -Overview: +> Please, open an issue if you see any errors in this repository. ## Some basic physics @@ -35,31 +36,31 @@ $$ ## Integration algorithms +$$ +\begin{align} +\Delta r = \vec v_1\Delta t_1 + \vec v_2\Delta t_2 + ... + \vec v_n\Delta t_n \\ +\Delta r = \lim_{\Delta t \to 0} \sum^n_{i=1}v_i\Delta t_i \\ +\Delta r = \int^{t_1}_{t_0} v(t)dt \\ +\end{align} +$$ + Now when we have current velocity, we need the way of shift precise calculation. The most obvious way is to use Euler's integration, but then we'll face an issue, that this way is highly dependent on simulation's refresh rate and ∆t between ticks. Fortunately, there's plenty of methods we can use instead. I've used [leapfrog algorithm](https://en.wikipedia.org/wiki/Leapfrog_integration). -### How it works? - - - ## Code logic - Universe - singleton class, that's capable of most calculations and kinetics acceleration - Kinetic - object that has physical parameters, such as mass, acceleration and velocity. It's also used in visualization, converting own parameters to display self in pygame window. Also it can break into fragments if the external forces are much greater than it's own gravity. - Spawner can be added into unifile. Spawnables must contain no parameters in constructor. - Universe Utils file specifies global mathematical operations, such as distance calculating, force between two kinetics and universe-to-display convertations -- Simulation - controls tickrate and Universe update rate. Parameters can be tweaked to achieve different simulation speed. - -Universe yses leapfrog integration for kinetic position and velocity calculations. - -> Graphics and visuals will be added soon +- Simulation - controls tick rate and Universe update rate. Parameters can be tweaked to achieve different simulation speed. ## Installing 1. Fetch the dependencies. `pip install -e .` -2. Optionally install jupiter +2. Optionally install Jupiter and Notebook with `pip install jupiter notebook` ## Usage @@ -79,12 +80,14 @@ Asteriod spawn params can be changed inside kinetic module in `AsteroidSpawner` Simply run `python src/main.py` to launch your `unifile.py` simulation -## Testing +### 3. Testing Jupiter notebook contains some basic computing and graphic plotting. It shows orbit parameters, speed and energy drift of a kinetic object. -> Currently there's no unit tests. Even basic. This must be changed asap - Codestyle checks are performed with `pylint`, simply run `pylint src` -> This file will change soon. +### 4. Jupiter graphics + +- Launch Jupiter with `jupiter notebook` command +- Open `README.ipynb` file +- Launch the notebook From 45c092d9baf7e401560be191454224fbcd953e91 Mon Sep 17 00:00:00 2001 From: Keyboard Destroyer Date: Thu, 20 Feb 2025 23:54:31 +0300 Subject: [PATCH 6/6] Updated python version for automation --- .github/workflows/pylint.yml | 2 +- .github/workflows/pytest.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 9006d92..3c0da44 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 4be57c1..a98892d 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -26,6 +26,6 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pytest - - name: Analysing the code with pylint + - name: Pytest run run: | pytest