diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index f1d981a..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 }} @@ -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..a98892d --- /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.12"] + 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: Pytest run + run: | + pytest 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 diff --git a/tests/test_common.py b/src/config/__init__.py similarity index 100% rename from tests/test_common.py rename to src/config/__init__.py 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/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..92c4fb2 100644 --- a/src/physics/kinetic.py +++ b/src/physics/kinetic.py @@ -4,6 +4,7 @@ from copy import copy import math import random + import pygame import simulation @@ -11,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 @@ -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): @@ -284,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 af92580..e427060 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 @@ -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: 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 b038965..c52c893 100644 --- a/src/simulation.py +++ b/src/simulation.py @@ -2,16 +2,15 @@ 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 + +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: @@ -24,8 +23,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): @@ -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): 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_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..c996e92 --- /dev/null +++ b/tests/test_physics.py @@ -0,0 +1,167 @@ +""" +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 +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() + + +@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()