I am a junior Software Engineer, C++ is usually my main jam but I started picking up Python for a research project I am doing in college. I am eager to learn as much Python syntax, tricks, best practices or software architecture in general as possible. So here is a portion of my project ~300 lines of code that simulate two types of protein movement through Brownian diffusion: diffusion in small circular domains (nanodomain simulation) and in a meshwork with compartments (hop diffusion). I hope I can get any tips and feedback on the code!
Project Structure
.
├── main.py
├── plotGenerator.py
├── requirements-dev.txt
├── simulations
│ ├── __init__.py
│ ├── hopDiffusionSimulation.py
│ ├── nanodomainSimulation.py
│ └── simulation.py
└── util.py
In Action Example
The Code
./main.py
from simulations.simulation import *
from simulations.nanodomainSimulation import *
from simulations.hopDiffusionSimulation import *
from plotGenerator import *
from util import *
#nanoDomain = Nanodomain()
hopDiffusion = HopDiffusion();
plot(hopDiffusion, SimulationType.HOPDIFFUSION)
./simulations/simulation.py
from typing import List, Tuple
import random
import numpy as np
from enum import Enum
class SimulationType(Enum):
BROWNIAN = 1
NANODOMAIN = 2
HOPDIFFUSION = 3
PATH = Tuple[List[float]]
DPI = 100
RADIUS_PADDING = 10
RADIUS = 250
CORRECTED_CANVAS_RADIUS = RADIUS - RADIUS_PADDING
TIME_PER_FRAME: float = 0.02 # 20 ms
DIFFUSION_SPEED_CORRECTION: int = 35 # arbitrary
MEMBRANE_DIFFUSION_COEFFICIENT: float = 0.1 # micrometer^2 / s
MEMBRANE_DIFFUSION_FACTOR: float = 2 * np.sqrt(MEMBRANE_DIFFUSION_COEFFICIENT * TIME_PER_FRAME)
MEMBRANE_DIFFUSION_FATOR_CORRECTED: float = MEMBRANE_DIFFUSION_FACTOR * DIFFUSION_SPEED_CORRECTION
class Simulation:
def __init__(self, n: int = 5):
self.numberOfParticles: int = n
self.particlesLocation: List[Tuple[int, int]] = []
self.paths: List[List[Tuple[int, int]]] = []
self.init_particles()
self.init_paths()
def init_paths(self):
self.paths.extend([[coordinate] for coordinate in self.particlesLocation])
def init_particles(self) -> None:
mem: List[Tuple] = []
def get_random_canvas_value(self) -> int:
return int(random.randint(-(CORRECTED_CANVAS_RADIUS), CORRECTED_CANVAS_RADIUS))
def rec(self, x: int = 0, y: int = 0) -> Tuple[int, int]:
x, y = [get_random_canvas_value(self) for _ in range(2)]
while (x, y) in mem:
return rec(self, x, y)
mem.append((x, y))
return x,y
self.particlesLocation.extend([rec(self) for _ in range(5)])
./simulations/hopDiffusionSimulation.py
from typing import List, Tuple
from simulations.simulation import *
from util import *
BOUNDARY_THICKNESS: int = 15
NUMBER_OF_COMPARTMENTS_PER_DIRECTION: int = 3
BOUNDARY_JUMP: int = BOUNDARY_THICKNESS
BOUNDARY_OVERFLOW: int = 20
HOP_PROBABILITY_PERCENTAGE: float = 0.15
class HopDiffusion(Simulation):
def __init__(self, n: int = 5):
self.boundary_coordinates_for_plot: List[List] = []
self.boundary_coordinates: List[Tuple[Tuple]] = []
self.generate_boundaries()
super().__init__(n)
def generate_boundaries(self):
step: int = int((RADIUS << 1) / NUMBER_OF_COMPARTMENTS_PER_DIRECTION)
for i in range(6):
if i % 3 == 0: continue
horizontal: bool = i < NUMBER_OF_COMPARTMENTS_PER_DIRECTION
curr = i * step if horizontal else (i - NUMBER_OF_COMPARTMENTS_PER_DIRECTION) * step
width = BOUNDARY_THICKNESS if horizontal else (RADIUS << 1) + (BOUNDARY_OVERFLOW << 1)
height = BOUNDARY_THICKNESS if not horizontal else (RADIUS << 1) + (BOUNDARY_OVERFLOW << 1)
x = curr - RADIUS - (BOUNDARY_THICKNESS >> 1) if horizontal else -RADIUS - BOUNDARY_OVERFLOW
y = curr - RADIUS - (BOUNDARY_THICKNESS >> 1) if not horizontal else -RADIUS - BOUNDARY_OVERFLOW
self.boundary_coordinates_for_plot.append(list([x, y, width, height]))
self.boundary_coordinates.append(list([tuple((x, x + width)), tuple((y, y + height))]))
@property
def get_boundary_coordinates(self):
return self.boundary_coordinates_for_plot
def can_particle_hop_boundary_probability(self) -> bool:
return random.random() < HOP_PROBABILITY_PERCENTAGE
def is_particle_on_specific_boudnary(self, pos: Tuple, idx: int):
return Util.is_point_within_bounds(pos, self.boundary_coordinates[idx])
def is_particle_on_boundary(self, pos: Tuple):
return any(
Util.is_point_within_bounds(pos, bounds_of_boundary)
for bounds_of_boundary in self.boundary_coordinates
)
def is_particle_in_compartment(self, particle) -> bool:
return not self.is_particle_on_boundary(particle)
def get_surrounding_boundary_of_particle(self, pos: Tuple) -> int:
for idx, bounds_of_boundary in enumerate(self.boundary_coordinates):
if Util.is_point_within_bounds(pos, bounds_of_boundary):
return idx
return -1
def make_particle_jump(self, newPos: Tuple, x_dir: int, y_dir: int):
surrounding_boundary_idx = self.get_surrounding_boundary_of_particle(newPos)
while (self.is_particle_on_specific_boudnary(newPos, surrounding_boundary_idx)):
newPos = Util.increment_tuple_by_val(
newPos, tuple((Util.sign(x_dir), Util.sign(y_dir)))
)
newPos = Util.increment_tuple_by_val(
newPos, tuple(
(Util.sign(x_dir) * BOUNDARY_JUMP,
Util.sign(y_dir) * BOUNDARY_JUMP)
)
)
# Special case: In some instances the jump may land the particle
# on a subsequent boundary so we repeat the function. We decrement
# the particle's coordinates until it is out.
new_surrounding_boundary_idx = self.get_surrounding_boundary_of_particle(newPos)
while (self.is_particle_on_boundary(newPos)):
newPos = Util.increment_tuple_by_val(
newPos, tuple((Util.sign(-x_dir), Util.sign(-y_dir)))
)
return newPos
def update_path(self, idx: int):
x, y = self.paths[idx][-1]
assert(not self.is_particle_on_boundary(tuple((x, y))))
diffusion_factor = MEMBRANE_DIFFUSION_FATOR_CORRECTED
x_dir, y_dir = [Util.get_random_normal_direction() * diffusion_factor for _ in range(2)]
newPos = tuple((x + x_dir, y + y_dir))
if self.is_particle_on_boundary(newPos):
if self.can_particle_hop_boundary_probability():
newPos = self.make_particle_jump(newPos, x_dir, y_dir)
else:
newPos = Util.change_direction(tuple((x, y)), tuple((x_dir, y_dir)))
self.paths[idx].append(newPos)
def update(self):
for i in range(self.numberOfParticles): self.update_path(i)
def init_particles(self) -> None:
mem: List[Tuple[int, int]] = []
def get_random_canvas_value(self) -> int:
return int(random.randint(-(CORRECTED_CANVAS_RADIUS), CORRECTED_CANVAS_RADIUS))
def rec(self, x: int = 0, y: int = 0) -> Tuple[int, int]:
x, y = [get_random_canvas_value(self) for _ in range(2)]
while (x, y) in mem or self.is_particle_on_boundary(tuple((x, y))):
return rec(self, x, y)
mem.append((x, y))
return x, y
self.particlesLocation.extend([rec(self) for _ in range(5)])
./simulations/nanodomainDiffusionSimulation.py
from typing import List, Tuple
from simulations.simulation import *
from util import *
NANODOMAIN_DIFFUSION_FATOR_CORRECTED: float = MEMBRANE_DIFFUSION_FATOR_CORRECTED * 0.4 # type : ignore
class Nanodomain(Simulation):
def __init__(self, n: int = 5):
super().__init__(n)
self.nanodomain_coordinates: List[Tuple[int, int]] = [
(-100, 100), (0, 0), (150, -60), (-130, -160)
]
self.nanodomain_radii: List[int] = [80, 20, 50, 140]
@property
def get_nanodomain_coordinates(self) -> List[Tuple[int, int]]:
return self.nanodomain_coordinates
@property
def get_nanodomain_radii(self) -> List[int]:
return self.nanodomain_radii
def get_nanodomain_attributes(self) -> List[Tuple]:
return list(map(
lambda coord, radius: (coord, radius),
self.get_nanodomain_coordinates,
self.get_nanodomain_radii
))
def is_particle_in_nanodomain(self, particle: Tuple) -> bool:
return any(
Util.compute_distance(particle, circle_center) <= radius
for circle_center, radius in
zip(self.get_nanodomain_coordinates, self.get_nanodomain_radii)
)
def update_path(self, idx):
x, y = self.paths[idx][-1]
diffusion_factor = NANODOMAIN_DIFFUSION_FATOR_CORRECTED if (self.is_particle_in_nanodomain((x, y))) else MEMBRANE_DIFFUSION_FATOR_CORRECTED
x_dir, y_dir = [Util.get_random_normal_direction() * diffusion_factor for _ in range(2)]
self.paths[idx].append((x + x_dir, y + y_dir))
def update(self):
[self.update_path(i) for i in range(self.numberOfParticles)]
./plotGenerator.py
from simulations.hopDiffusionSimulation import HopDiffusion
from simulations.nanodomainSimulation import Nanodomain
from simulations.simulation import *
from util import *
from matplotlib.animation import FuncAnimation # type: ignore
from matplotlib.pyplot import figure
import matplotlib.pyplot as plt
from typing import List, Tuple
import numpy as np
from matplotlib import rcParams # type: ignore
colors: List[str] = ['r', 'b', "orange", 'g', 'y', 'c']
markers: List[str] = ['o', 'v', '<', '>', 's', 'p']
def handle_nanodomain(ax, sim: Nanodomain):
nanodomains = [
plt.Circle( # type: ignore
*param,
color = 'black',
alpha = 0.2)
for param in sim.get_nanodomain_attributes()
]
[ax.add_patch(nanodomain) for nanodomain in nanodomains]
def handle_hop_diffusion(ax, sim: HopDiffusion):
compartments = [
plt.Rectangle( # type: ignore
tuple((param[0], param[1])),
param[2], param[3],
color = 'black',
alpha = 0.7,
clip_on = False)
for param in sim.boundary_coordinates_for_plot
]
[ax.add_patch(boundary) for boundary in compartments]
def get_coordinates_for_plot(sim, idx: int):
return Util.get_x_coordinates(sim.paths[idx]), Util.get_y_coordinates(sim.paths[idx])
def get_coordinates_for_heads(sim, idx: int):
return Util.get_last_point(sim.paths[idx])
def set_plot_parameters(ax):
ax.tick_params(axis = 'y', direction = "in", right = True, labelsize = 16, pad = 20)
ax.tick_params(axis = 'x', direction = "in", top = True, bottom = True, labelsize = 16, pad = 20)
## legends and utilities
ax.set_xlabel(r"nm", fontsize=16)
ax.set_ylabel(r"nm", fontsize=16)
## border colors
ax.patch.set_edgecolor('black')
ax.patch.set_linewidth('2')
ax.set_xlim(-RADIUS, RADIUS)
ax.set_ylim(-RADIUS, RADIUS)
def plot(sim: Simulation, type: SimulationType):
fig, ax = plt.subplots(figsize = [5, 5], dpi = DPI) # type: ignore
path_plots: List = [
ax.plot(
*get_coordinates_for_plot(sim, i),
markersize=15, color = colors[i])[0]
for i in range(5)
]
head_plots: List = [
ax.plot(
*get_coordinates_for_heads(sim, i),
markersize=7, color = colors[i], marker = markers[i],
markerfacecolor="white")[0]
for i in range(5)
]
def initialize_animation():
set_plot_parameters(ax)
if type == SimulationType.NANODOMAIN: handle_nanodomain(ax, sim)
elif type == SimulationType.HOPDIFFUSION: handle_hop_diffusion(ax, sim)
return path_plots
def update_animation(frame):
sim.update()
for i, plot in enumerate(path_plots):
plot.set_data(*get_coordinates_for_plot(sim, i))
for i, head_marker in enumerate(head_plots):
head_marker.set_data(*get_coordinates_for_heads(sim, i))
return path_plots
animation = FuncAnimation(
fig,
update_animation,
init_func = initialize_animation,
interval = 20
)
plt.show(block = True) # type: ignore
fig.tight_layout()
rcParams.update({'figure.autolayout': True})
./util.py
from typing import List, Tuple
import numpy as np
import random
class Util:
@staticmethod
def get_bounds(lists) -> Tuple[int, ...]:
x_min: int = min([min(elem[0]) for elem in lists])
x_max: int = max([max(elem[0]) for elem in lists])
y_min: int = min([min(elem[1]) for elem in lists])
y_max: int = max([max(elem[1]) for elem in lists])
return x_min, x_max, y_min, y_max
@staticmethod
def compute_distance(p1: Tuple, p2: Tuple) -> float:
return np.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
@staticmethod
def get_last_point(path: List[Tuple]) -> Tuple[int, ...]:
return path[-1][0], path[-1][1]
@staticmethod
def get_x_coordinates(path) -> List:
return list(list(zip(*path))[0])
@staticmethod
def get_y_coordinates(path) -> List:
return list(list(zip(*path))[1])
@staticmethod
def get_random_normal_direction():
return np.random.normal() * np.random.choice([1, -1])
@staticmethod
def is_point_within_bounds(pos: Tuple, bounds: Tuple[Tuple, ...]):
x, y = pos[0], pos[1]
return x >= bounds[0][0] and x <= bounds[0][1] and y >= bounds[1][0] and y <= bounds[1][1]
@staticmethod
def sign(x):
return ((x >= 0) << 1) - 1
@staticmethod
def increment_tuple_by_val(tuple_object: Tuple, val):
tuple_object = tuple((tuple_object[0] + val[0], tuple_object[1] + val[1]))
return tuple_object
@staticmethod
def change_direction(tuple_object: Tuple, dir):
tuple_object = tuple((tuple_object[0] - dir[0], tuple_object[1] - dir[1]))
return tuple_object
np.random.normal() * np.random.choice([1, -1])
is suspicious.normal()
carries defaults ofloc=0.0, scale=1.0
, so the choice of sign is already random. I think you can get away with dropping the second term, but I'd like to confirm with you what your intent was. \$\endgroup\$