#!/usr/bin/env python
"""Module that implements Cell and Cluster related classes."""
try:
# noinspection PyUnresolvedReferences
from matplotlib import patches
# noinspection PyUnresolvedReferences
from matplotlib import pyplot as plt
_MATPLOTLIB_AVAILABLE = True
except ImportError: # pragma: no cover
_MATPLOTLIB_AVAILABLE = False
import cmath
import itertools
import math
from collections.abc import Iterable
from io import BytesIO
from typing import Any, Dict
from typing import Iterable as Iterable_t # distinguish it from collections.abc.Iterable
from typing import Iterator, List, Optional, Tuple, Type, Union, cast
import numpy as np
from ..cell import shapes
__all__ = [
'Node', 'AccessPoint', 'CellBase', 'Cell', 'Cell3Sec', 'CellSquare',
'CellWrap', 'Cluster', 'Grid'
]
IntOrIntIterable = Union[Iterable_t[int], int]
FloatOrFloatIterable = Union[Iterable_t[float], float]
StrOrStrIterable = Union[Iterable_t[str], str]
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxx Node class xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs]class Node(shapes.Coordinate):
"""
Class representing a node in the network.
Parameters
----------
pos : complex
The position of the node in the complex grid.
plot_marker : str
The marker to be used in a plot to represent the Node. This
marker should be something that matplotlib can understand, such
as '*', for instance.
marker_color : str
The color that will be used to plot the marker representing the
Node. This color should be something that matplotlib can
understand, such as 'r' for the color red, for instance.
cell_id : str, int, optional
The ID of the cell where the Node is located.
parent_pos : complex
The position of the cell where the Node is located (if any).
"""
def __init__(self,
pos: complex,
plot_marker: str = '*',
marker_color: str = 'r',
cell_id: Optional[Union[str, int]] = None,
parent_pos: Optional[complex] = None) -> None:
super().__init__(pos)
self.plot_marker: str = plot_marker
self.marker_color: str = marker_color
self.marker_size: int = 6 # Changing this value will affect only the plot
# ID of the cell where the user is located
self.cell_id: Optional[Union[str, int]] = cell_id
self._relative_pos: Optional[complex] = None
if parent_pos is not None:
self._relative_pos = pos - parent_pos
@property
def relative_pos(self) -> Optional[complex]:
"""
Get method for the relative_pos property.
Returns
-------
complex | None
The relative position of the Node regarding its parent
Node's position.
"""
return self._relative_pos
[docs] def plot_node(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the node using the matplotlib library.
If an axes 'ax' is specified, then the node is added to that
axes. Otherwise a new figure and axes are created and the node is
plotted to that.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the node will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
ax = plt.axes()
stand_alone_plot = True
ax.plot(self.pos.real,
self.pos.imag,
marker=self.plot_marker,
markerfacecolor=self.marker_color,
markeredgecolor=self.marker_color,
markersize=self.marker_size)
if stand_alone_plot is True:
ax.plot()
plt.show()
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxx AccessPoint class xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs]class AccessPoint(Node):
"""
Access point class.
An access point acts as a transmitter to one of more users.
Parameters
----------
pos : complex
The central position of the cell in the complex grid.
ap_id : int, str, optional
The AccessPoint ID. If not provided the access point won't have
an ID and its plot will shown a symbol at the access point
location instead of the ID.
"""
def __init__(self,
pos: complex,
ap_id: Optional[Union[int, str]] = None) -> None:
super().__init__(pos, plot_marker='^', marker_color='b', cell_id=ap_id)
# List to store the users associated with this access point
self._users: List[Node] = []
# ID of the access point
self.id: Optional[Union[int, str]] = ap_id
# xxxxx Appearance for plotting xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Set this to a number. If None, default value for Matplotlib will
# be used.
self.id_fontsize: Optional[int] = None
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
def __repr__(self) -> str: # pragma: nocover
"""
Representation of a AccessPoint object.
Returns
-------
str
The string representation of the AccessPoint.
"""
return "{0}(pos={1},ap_id={2})".format(self.__class__.__name__,
self.pos, self.id)
# The set pos property inherited from the Coordinate class only changes
# the self._pos variable. We re-implement it here so that when the
# position is changed we update the positions of any associated
# user. Notice how we are only changing the 'set' part of the property
# defined in the Coordinate base class.
@property
def pos(self) -> complex:
"""
Get the AccessPoint position.
Returns
-------
complex
The AccessPoint position.
"""
return self._pos
@pos.setter
def pos(self, value: complex) -> None:
"""
Set the AccessPoint position.
Parameters
----------
value : complex
The new AccessPoint position.
"""
diff = value - self._pos
self._pos = value
for user in self._users:
user.pos += diff
@property
def num_users(self) -> int:
"""
Get method for the num_users property.
Returns
-------
int
The number of users associated with the AccessPoint.
"""
return len(self._users)
@property
def users(self) -> List[Node]:
"""
Get method for the users property.
Returns
-------
list[Node]
The users associated with the AccessPoint.
"""
return self._users
[docs] def delete_all_users(self) -> None:
"""Delete all users from the cell.
"""
self._users = []
[docs] def add_user(self, new_user: Node, relative_pos_bool: bool = True) -> None:
"""
Associate a new user with the access point.
Parameters
----------
new_user : Node
The new user to be associated with the access point.
relative_pos_bool : bool
Indicates it the position of the `new_user` is relative.
"""
new_user.cell_id = self.id
self._users.append(new_user)
# noinspection PyUnresolvedReferences
[docs] def _plot_common_part(self, ax: Any) -> None: # pragma: no cover
"""
Common code for plotting the classes. Each subclass must
implement a `plot` method in which it calls the command to plot
the class shape followed by _plot_common_part.
Parameters
----------
ax : A matplotlib axis
The axis where the cell will be plotted.
"""
# If self.id is None, plot a single marker at the center of the
# cell
if self.id is None:
self.plot_node(ax)
else:
# If self.id is not None, plot the cell ID at the center of the
# cell
# noinspection PyUnresolvedReferences
plt.text(self.pos.real,
self.pos.imag,
'{0}'.format(self.id),
color=self.marker_color,
horizontalalignment='center',
verticalalignment='center',
fontsize=self.id_fontsize)
# Now we plot all the users in the cell
for user in self.users:
user.plot_node(ax)
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the AccessPoint using the matplotlib library.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cell will be plotted. If not provided, a new
figure (and axis) will be created.
"""
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots()
# Plot the node part as well as the users in the cell
self._plot_common_part(ax)
# ax.set_ylim([-1, 1])
# ax.set_xlim([-1, 1])
plt.draw()
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxx Cell classes xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# TODO: maybe refactor this class so that we don't inherit from any
# shape and use composition instead
# noinspection PyAbstractClass
[docs]class CellBase(shapes.Shape, AccessPoint): # pylint: disable=W0223
"""
Base class for all cell types.
A cell is an AccessPoint with a predefined shape, where the users
associated with it are inside the shape.
Parameters
----------
pos : complex
The central position of the cell in the complex grid.
radius : float
The cell radius.
cell_id : str, int, optional
The cell ID. If not provided the cell won't have an ID and its
plot will shown a symbol in cell center instead of the cell ID.
rotation : float, optional
The rotation of the cell (regarding the cell center).
"""
def __init__(self,
pos: complex,
radius: float,
cell_id: Optional[Union[str, int]] = None,
rotation: float = 0.0,
**kw) -> None:
super().__init__(pos=pos,
radius=radius,
rotation=rotation,
ap_id=cell_id,
**kw)
def __repr__(self) -> str:
"""
Representation of a CellBase object.
Returns
-------
str
The string representation of the CellBase.
"""
return "{0}(pos={1},radius={2},cell_id={3},rotation={4})".format(
self.__class__.__name__, self.pos, self.radius, self.id,
self.rotation)
[docs] def add_user(self, new_user: Node, relative_pos_bool: bool = True) -> None:
"""
Adds a new user to the cell.
Parameters
----------
new_user : Node
The new user to be added to the cell.
relative_pos_bool : bool, optional (default to True)
Indicates if the 'pos' attribute of the `new_user` is relative
to the center of the cell or not.
Returns
-------
None
Raises
------
ValueError
If the user position is outside the cell (the user won't be
added).
"""
if isinstance(new_user, Node):
if relative_pos_bool is True:
# If the position of the user is relative to the cell, that
# means that the real and imaginary parts of new_user.pos
# are in the [-1, 1] range. We need to convert them to an
# absolute coordinate.
new_user.pos = new_user.pos * self.radius + self.pos
if self.is_point_inside_shape(new_user.pos):
# pylint: disable=W0212
new_user.cell_id = self.id
new_user._relative_pos = new_user.pos - self.pos
self._users.append(new_user)
else:
raise ValueError("User position is outside the cell -> "
"User not added")
else:
raise TypeError("User must be Node object.")
[docs] def add_border_user(
self,
angles: Union[float, Iterable_t[float]],
ratio: Optional[Union[float, Iterable_t[float]]] = None,
user_color: Optional[Union[str, Iterable_t[str]]] = None) -> None:
"""
Adds a user at the border of the cell, located at a specified
angle (in degrees).
If the `angles` variable is an iterable, one user will be added
for each value in `angles`. Also, in that case ration and
user_color may also be iterable with the same length of `angles`
in order to specify individual ratio and user_color for each
angle.
Parameters
----------
angles : float | list[float] | np.ndarray
Angle(s) for which users will be added (may be a single
number or an iterable).
ratio : float | list[float] | np.ndarray | None
The ration (relative distance from cell center) for which users
will be added (may be a single number or an iterable). If not
specified the users will be added to the cell's border at the
angles specified in `angles`.
user_color : str | list[str], optional
Color of the user's marker.
Raises
------
ValueError
If the ratio is invalid (negative or greater than 1).
"""
# Assures that angle is an iterable
if not isinstance(angles, Iterable):
angles = [angles]
if user_color is None:
user_color = itertools.repeat(user_color) # type: ignore
elif isinstance(user_color, str):
user_color = itertools.repeat(user_color)
if isinstance(ratio, float):
ratio = CellBase._validate_ratio(ratio)
ratio = itertools.repeat(ratio)
elif ratio is None:
ratio = itertools.repeat(None) # type: ignore
else:
assert (isinstance(ratio, Iterable))
ratio = [CellBase._validate_ratio(i) for i in ratio]
assert (isinstance(ratio, Iterable))
assert (isinstance(user_color, Iterable))
all_data = zip(angles, ratio, user_color)
for data in all_data:
a, r, c = data
new_user = Node(self.get_border_point(a, r),
cell_id=self.id,
parent_pos=self.pos)
if c is not None:
new_user.marker_color = c
self._users.append(new_user)
[docs] def add_random_user(self,
user_color: Optional[str] = None,
min_dist_ratio: float = 0.0) -> None:
"""
Adds a user randomly located in the cell.
The variable `user_color` can be any color that the plot command
and friends can understand. If not specified the default value
of the node class will be used.
Parameters
----------
user_color : str, optional
Color of the user's marker.
min_dist_ratio : float
Minimum allowed (relative) distance between the cell center and
the generated random user. The value must be between 0 and 0.7.
"""
# Creates a new user. Note that this user can be invalid (outside
# the cell) or not.
pos = (self.pos +
complex(2 * (np.random.random_sample() - 0.5) * self.radius, 2 *
(np.random.random_sample() - 0.5) * self.radius))
new_user = Node(pos, cell_id=self.id, parent_pos=self.pos)
# noinspection PyPep8
while not self.is_point_inside_shape(
new_user.pos) or (self.calc_dist(new_user) <
(min_dist_ratio * self.radius)):
# Create another, since the previous one is not valid
pos = (self.pos +
complex(2 *
(np.random.random_sample() - 0.5) * self.radius, 2 *
(np.random.random_sample() - 0.5) * self.radius))
new_user = Node(pos, cell_id=self.id, parent_pos=self.pos)
if user_color is not None:
new_user.marker_color = user_color
# Finally add the user to the cell
self.add_user(new_user, relative_pos_bool=False)
[docs] def add_random_users(self,
num_users: int,
user_color: Optional[str] = None,
min_dist_ratio: float = 0.0) -> None:
"""
Add `num_users` users randomly located in the cell.
Parameters
----------
num_users : int
Number of users to be added to the cell.
user_color : str, optional
Color of the user's marker.
min_dist_ratio : float
Minimum allowed (relative) distance between the cell center and
the generated random user. The value must be between 0 and 0.7.
"""
for _ in range(num_users):
self.add_random_user(user_color, min_dist_ratio)
[docs] def plot_border(self,
ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the border of the cell.
If an axes 'ax' is specified, then the shape is added to that
axis. Otherwise a new figure and axis are created and the shape is
plotted to that.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cell will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# Plot the border of the cell
shapes.Shape.plot(self, ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs] @staticmethod
def _validate_ratio(ratio: float) -> float:
"""Return `ratio` if is valid, 1.0 if `ratio` is None, or throw an
exception if it is not valid.
This is a helper method used in the add_border_user method
implementation.
Parameters
----------
ratio : float
The ratio (a number between 0 and 1).
Returns
-------
ratio : float
The valid ratio. If ratio parameter was 'None' then 1.0 will be
returned.
Raises
------
ValueError
If `ratio` is not between 0 and 1.
"""
# If ratio is None then it was not specified and we assume it to be
# equal to one (border of the shape). However, if we set ratio to
# be exactly 1.0 then the is_point_inside_shape method would
# return false which is probably not what you want. Therefore, we
# set it to be a little bit lower then 1.0.
if ratio == 1.0:
ratio = 1.0 - 1e-15
else:
if (ratio < 0) or (ratio > 1):
raise ValueError("ratio must be between 0 and 1")
return ratio
[docs]class Cell(shapes.Hexagon, CellBase):
"""Class representing an hexagon cell.
Parameters
----------
pos : complex
The central position of the cell in the complex grid.
radius : float
The cell radius.
cell_id : str, int, optional
The cell ID. If not provided the cell won't have an ID and its
plot will shown a symbol in cell center instead of the cell ID.
rotation : float, optional
The rotation of the cell (regarding the cell center).
"""
# noinspection PyCallByClass
def __init__(self,
pos: complex,
radius: float,
cell_id: Optional[Union[str, int]] = None,
rotation: float = 0.0) -> None:
super().__init__(pos=pos,
radius=radius,
rotation=rotation,
cell_id=cell_id)
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the cell using the matplotlib library.
If an axes 'ax' is specified, then the shape is added to that
axis. Otherwise a new figure and axis are created and the shape is
plotted to that.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cell will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# Plot the shape part
# noinspection PyCallByClass
shapes.Hexagon.plot(self, ax)
# Plot the node part as well as the users in the cell
self._plot_common_part(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs]class Cell3Sec(CellBase):
"""
Class representing a cell with 3 sectors.
Each sector corresponds to an hexagon.
Parameters
----------
pos : complex
The central position of the cell in the complex grid.
radius : float
The cell radius. The sector radius will be equal to half the
cell radius.
cell_id : str, int, optional
The cell ID. If not provided the cell won't have an ID and its
plot will shown a symbol in cell center instead of the cell ID.
rotation : float, optional
The rotation of the cell (regarding the cell center).
"""
def __init__(self,
pos: complex,
radius: float,
cell_id: Optional[Union[str, int]] = None,
rotation: float = 0.0) -> None:
super().__init__(pos, radius, cell_id, rotation)
sec_positions = self._calc_sectors_positions()
self._sec1 = Cell(sec_positions[0],
self.secradius,
cell_id=None,
rotation=self.rotation - 30)
self._sec2 = Cell(sec_positions[1],
self.secradius,
cell_id=None,
rotation=self.rotation - 30)
self._sec3 = Cell(sec_positions[2],
self.secradius,
cell_id=None,
rotation=self.rotation - 30)
[docs] def _calc_sectors_positions(self) -> np.ndarray:
"""
Calculates the positions of the sectors with the current
rotation, center position and radius.
Returns
-------
np.ndarray
The positions of the 3 sectors.
"""
secradius = self.secradius
h = secradius * (math.sqrt(3) / 2.0)
sec_positions: np.ndarray = np.empty(3, dtype=complex)
sec_positions[0] = 0 - h - (0.5j * secradius)
sec_positions[1] = 0 + h - (0.5j * secradius)
sec_positions[2] = 0 + (1j * secradius)
sec_positions = shapes.Shape.calc_rotated_pos(sec_positions,
self.rotation)
sec_positions += self.pos
return sec_positions
@property
def radius(self) -> float:
"""
Get the radius of the Cell3Sec object.
Returns
-------
float
The radius of the Cell3Sec object.
"""
return self._radius
@radius.setter
def radius(self, value: float) -> None:
"""
Set the radius of the Cell3Sec object.
Parameters
----------
value : float
The new radius of the Cell3Sec object.
"""
# Overwrite the set property for radius in the Shape parent class
# so that if radius is changed we update the radius of each sector.
self._radius = value
# self.secradius is updated when self.radius changes
secradius = self.secradius
self._sec1.radius = secradius
self._sec2.radius = secradius
self._sec3.radius = secradius
# When the radius change, we also need to update the position of
# each sector.
sec_positions = self._calc_sectors_positions()
self._sec1.pos = sec_positions[0]
self._sec2.pos = sec_positions[1]
self._sec3.pos = sec_positions[2]
@property
def rotation(self) -> float:
"""
Get method for the rotation property.
Returns
-------
float
The shape rotation.
"""
return self._rotation
@rotation.setter
def rotation(self, value: float) -> None:
"""
Set method for the rotation property.
Parameters
----------
value : float
The rotation angle (in degrees).
"""
# Overwrite the set property for rotation in the Shape parent class
# so that if rotation is changed we update the rotation of each
# sector.
self._rotation = value
self._sec1.rotation = value - 30
self._sec2.rotation = value - 30
self._sec3.rotation = value - 30
sec_positions = self._calc_sectors_positions()
self._sec1.pos = sec_positions[0]
self._sec2.pos = sec_positions[1]
self._sec3.pos = sec_positions[2]
@property
def pos(self) -> complex:
"""
Get the Cell3Sec position.
Returns
-------
complex
The Cell3Sec position.
"""
return self._pos
@pos.setter
def pos(self, value: complex) -> None:
"""
Set the Cell3Sec position.
Parameters
----------
value : complex
The new Cell3Sec position.
"""
# Calling the "set method" of the "pos" property of the CellBase
# class will not only update the position of the cell, but also
# update the position of any users already in the cell.
CellBase.pos.fset(self, value) # type: ignore
# Update the sectors' positions
sec_positions = self._calc_sectors_positions()
self._sec1.pos = sec_positions[0]
self._sec2.pos = sec_positions[1]
self._sec3.pos = sec_positions[2]
@property
def secradius(self) -> float:
"""
Get method for the secradius property.
The radius of a sector.
Returns
-------
float
The radius of one sector of the Cell3Sec object.
"""
# The value "sqrt(3) * r / 3" was chosen to be the section radius
# so that the area of the three sectorized cell with radius equal
# to `secradius` be the same of an hexagonal cell with radius equal
# to `r`.
return math.sqrt(3) * self.radius / 3.0
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Calculates the vertex positions ignoring any rotation and
considering that the shape is at the origin (rotation and
translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape.
"""
secradius = self.secradius
h = secradius * (math.sqrt(3) / 2.0)
# The three sectors are hexagons. We set their positions to the
# origin.
sec1 = shapes.Hexagon(0 - h - (0.5j * secradius),
secradius,
rotation=30)
sec2 = shapes.Hexagon(0 + h - (0.5j * secradius),
secradius,
rotation=30)
sec3 = shapes.Hexagon(0 + (1j * secradius), secradius, rotation=30)
# The vertexes of the whole cell correspond to the union of the
# vertexes of all sectors.
aux = [
sec1.vertices[[0, 1]], sec2.vertices[[0, 1, 2, 3]],
sec3.vertices[[2, 3, 4, 5]], sec1.vertices[[4, 5]]
]
all_vertexes: np.ndarray = np.hstack(aux)
return all_vertexes
[docs] def add_random_user_in_sector(self,
sector: int,
user_color: Optional[str] = None,
min_dist_ratio: float = 0.0) -> None:
"""
Adds a user randomly located in the specified `sector` of the cell.
Parameters
----------
sector : int
The sector index. Can only be 1, 2 or 3.
user_color : str
Color of the user's marker.
min_dist_ratio : float
Minimum allowed (relative) distance between the cell center and
the generated random user. The value must be between 0 and 0.7.
"""
if sector == 1:
sec = self._sec1
elif sector == 2:
sec = self._sec2
elif sector == 3:
sec = self._sec3
else:
raise RuntimeError('Invalid sector number: {0}'.format(sector))
sec.add_random_user(user_color, min_dist_ratio)
self._users.extend(sec.users)
sec.delete_all_users()
[docs] def add_random_users_in_sector(self,
num_users: int,
sector: int,
user_color: Optional[str] = None,
min_dist_ratio: float = 0.0) -> None:
"""
Add `num_users` users randomly in the specified `sector` of the
cell
Parameters
----------
num_users : int
Number of users to be added to the sector.
sector : int
The sector index. Can only be 1, 2 or 3.
user_color : str
Color of the user's marker.
min_dist_ratio : float
Minimum allowed (relative) distance between the cell center and
the generated random user. The value must be between 0 and 0.7.
"""
for _ in range(num_users):
self.add_random_user_in_sector(sector, user_color, min_dist_ratio)
# noinspection PyUnresolvedReferences
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the cell using the matplotlib library.
If an axes 'ax' is specified, then the shape is added to that
axis. Otherwise a new figure and axis are created and the shape is
plotted to that.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cell will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# Plot the shape part
shapes.Shape.plot(self, ax)
# xxxxxxxxxx Plot the dashed lines (border between sectors xxxxxxxx
rotation = self.rotation * math.pi / 180
angle = (math.pi / 6.) + rotation
p1 = (self.pos + (math.cos(angle) +
(math.sin(angle) * 1j)) * self.secradius)
angle += 2 * math.pi / 3.
p2 = (self.pos + (math.cos(angle) +
(math.sin(angle) * 1j)) * self.secradius)
angle += 2 * math.pi / 3.
# p3 = self.pos - 1j * self.secradius
p3 = (self.pos + (math.cos(angle) +
(math.sin(angle) * 1j)) * self.secradius)
line1 = plt.Line2D([self.pos.real, p1.real], [self.pos.imag, p1.imag],
linestyle='dashed',
color='black',
alpha=0.5)
line2 = plt.Line2D([self.pos.real, p2.real], [self.pos.imag, p2.imag],
linestyle='dashed',
color='black',
alpha=0.5)
line3 = plt.Line2D([self.pos.real, p3.real], [self.pos.imag, p3.imag],
linestyle='dashed',
color='black',
alpha=0.5)
ax.add_line(line1)
ax.add_line(line2)
ax.add_line(line3)
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Plot the node part as well as the users in the cell
self._plot_common_part(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs]class CellSquare(shapes.Rectangle, CellBase):
"""
Class representing a 'square' cell.
Parameters
----------
pos : complex
The central position of the cell in the complex grid.
side_length : float
The cell side length.
cell_id : str, int, optional
The cell ID. If not provided the cell won't have an ID and its
plot will shown a symbol in cell center instead of the cell ID.
rotation : float, optional
The rotation of the cell (regarding the cell center).
"""
# noinspection PyCallByClass
def __init__(self,
pos: complex,
side_length: float,
cell_id: Optional[Union[str, int]] = None,
rotation: float = 0.0) -> None:
half_side = side_length / 2.
first = pos - half_side - 1j * half_side
second = pos + half_side + 1j * half_side
radius = math.sqrt(2.0) * side_length / 2.
# super().__init__(first=first, second=second, rotation=rotation, pos=pos, radius=radius, cell_id=cell_id)
shapes.Rectangle.__init__(self, first, second, rotation)
CellBase.__init__(self, pos, radius, cell_id, rotation)
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the cell using the matplotlib library.
If an axes 'ax' is specified, then the shape is added to that
axis. Otherwise a new figure and axis are created and the shape is
plotted to that.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cell will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# Plot the shape part
# noinspection PyCallByClass
shapes.Rectangle.plot(self, ax)
# Plot the node part as well as the users in the cell
self._plot_common_part(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs] def add_user(self, new_user: Node, relative_pos_bool: bool = True) -> None:
"""
Adds a new user to the cell.
Parameters
----------
new_user : Node
The new user to be added to the cell.
relative_pos_bool : bool, optional
Indicates if the 'pos' attribute of the `new_user` is relative
to the center of the cell or not.
Raises
------
ValueError
If the user position is outside the cell (the user won't be
added).
"""
if isinstance(new_user, Node):
if relative_pos_bool is True:
# If the position of the user is relative to the cell, that
# means that the real and imaginary parts of new_user.pos
# are in the [-1, 1] range. We need to convert them to an
# absolute coordinate.
half_side = abs(self._lower_coord.real -
self._upper_coord.real) / 2
new_user.pos = new_user.pos * half_side + self.pos
if self.is_point_inside_shape(new_user.pos):
# pylint: disable=W0212
new_user.cell_id = self.id
new_user._relative_pos = new_user.pos - self.pos
self._users.append(new_user)
else:
raise ValueError("User position is outside the cell -> "
"User not added")
else:
raise TypeError("User must be Node object.")
[docs]class CellWrap(CellBase):
"""
Class that wraps another cell.
Parameters
----------
pos : complex
The central position where the wrapped cell will be in the
complex grid.
wrapped_cell : T <= CellBase
The wrapped cell. It must be an object of some subclass of
CellBase.
include_users_bool : bool
Set to True if the users of the original cells should appear in
the wrapped version.
"""
def __init__(self,
pos: complex,
wrapped_cell: CellBase,
include_users_bool: bool = False):
assert isinstance(wrapped_cell, CellBase), \
'wrapped_cell must be a subclass of CellBase'
# Except for the _wrapped_cell member variable below, all other
# member variables are defined in some base class of CellWrap.
self._wrapped_cell = wrapped_cell
# If True, users of the wrapped cells will be included as users of
# the CellWrap object. Otherwise the CellWrap object will have 0
# users.
self.include_users_bool: bool = include_users_bool
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
radius = wrapped_cell.radius
rotation = wrapped_cell.rotation
cell_id: Optional[str]
if wrapped_cell.id is not None:
cell_id = "Wrap {0}".format(wrapped_cell.id)
else:
cell_id = None
super().__init__(pos, radius, cell_id, rotation)
self.fill_face_bool: bool = True
self.fill_color: str = 'gray'
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def radius(self) -> float:
"""
Get the radius of the CellWrap object.
Returns
-------
float
The radius of the CellWrap object.
"""
return self._wrapped_cell.radius
@radius.setter
def radius(self, value: float) -> None:
"""
Set the radius of the CellWrap object.
Parameters
----------
value : float
The new radius of the CellWrap object.
"""
raise AttributeError("The radius of a CellWrap should not be changed")
@property
def rotation(self) -> float:
"""
Get the rotation of the CellWrap object.
Returns
-------
float
The rotation of the CellWrap object.
"""
return self._wrapped_cell.rotation
@rotation.setter
def rotation(self, value: float) -> None:
"""
Set method for the rotation property.
Parameters
----------
value : float
The new rotation value.
"""
raise AttributeError(
"The rotation of a CellWrap should not be changed")
@property
def num_users(self) -> int:
"""
Get method for the num_users property.
Returns
-------
int
The number of users associated with the AccessPoint.
"""
if self.include_users_bool is True:
return self._wrapped_cell.num_users
return 0
@property
def users(self) -> List[Node]:
"""
Get method for the users property.
Returns
-------
list
The users associated with the AccessPoint.
"""
users: List[Node]
if self.include_users_bool is True:
wrapped_cell_pos = self._wrapped_cell.pos
users = [
Node(u.pos - wrapped_cell_pos + self.pos,
marker_color='g',
parent_pos=self.pos) for u in self._wrapped_cell.users
]
else:
users = []
return users
def __repr__(self) -> str:
"""
Representation of a CellWrap object.
Returns
-------
str
The string representation of the CellWrap.
"""
return "{0}(pos={1},cell_id={2})".format(self.__class__.__name__,
self.pos, self.id)
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Calculates the vertex positions ignoring any rotation and
considering that the shape is at the origin (rotation and
translation will be added automatically later).
Returns
-------
vertex_positions : np.ndarray
The positions of the vertexes of the shape.
"""
return self._wrapped_cell.vertices_no_trans_no_rotation
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# Plot the shape part
shapes.Shape.plot(self, ax)
# Plot the node part as well as the users in the cell
self._plot_common_part(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxx Cluster Class xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs]class Cluster(shapes.Shape):
"""
Class representing a cluster of Hexagonal cells.
Valid cluster sizes are given by the formula
:math:`N = i^2+i*j+j^2`
where i and j are integer numbers. The allowed values in the Cluster
class are summarized below with the corresponding values of i and j.
==== ===
i, j N
==== ===
1,0 01
1,1 03
2,0 04
2,1 07
3,1 13
3,2 19
==== ===
Parameters
----------
cell_radius : float
Radius of the cells in the cluster.
num_cells : int
Number of cells in the cluster.
pos : complex
Central Position of the Cluster in the complex grid.
cluster_id : int
ID of the cluster.
cell_type : str
The type of the cell as a string. It can be either 'simple',
'3sec' or 'square'.
If it is 'simple' it means the standard hexagon shaped cell. If
'3sec' it means a 3 sectorized cell composed of 3 hexagons.
rotation : float
Rotation of the cluster.
"""
_ii_and_jj = {
1: (1, 0),
3: (1, 1),
4: (2, 0),
7: (2, 1),
13: (3, 1),
19: (3, 2)
}
# Store cell positions in a cluster centered at the origin without any
# rotation and with a radius equal to one.
_normalized_cell_positions: Dict[int, np.ndarray] = {}
def __init__(self,
cell_radius: float,
num_cells: int,
pos: complex = 0 + 0j,
cluster_id: Optional[int] = None,
cell_type: str = 'simple',
rotation: float = 0.0) -> None:
super().__init__(pos=pos, radius=0, rotation=0)
# xxxxx Store for later reference (in __repr__) xxxxxxxxxxxxxxxxxxx
self._cell_type: str = cell_type
self._rotation: float = rotation
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
self.cluster_id: Optional[int] = cluster_id
self._cell_radius: float = cell_radius
# Cells in the cluster
self._cells: List[CellBase] = []
# Dictionary to store the wrapped cells (when wrap around is used)
self._wrapped_cells: Dict[str, CellWrap] = {}
# Each element is a list where the first element of that list if
# the position of the corresponding cell. The subsequent elements
# are the positions of that same cell wrapped somewhere.
self._cell_pos: List[complex] = []
# This will be set later as a 2D numpy array with the difference of
# the coordinates between each pair of cells (possibly considering
# wrap around)
self._cell_pos_diffs: Optional[
Iterable_t[complex]] = None # np.ndarray of complex numbers
cell_positions = Cluster._calc_cell_positions(cell_radius, num_cells,
cell_type, rotation)
# Correct the positions to take into account the grid central
# position.
cell_positions[:, 0] = cell_positions[:, 0] + self.pos
CELLCLASS: Type[CellBase]
if cell_type == 'simple':
CELLCLASS = Cell
elif cell_type == '3sec':
CELLCLASS = Cell3Sec
elif cell_type == 'square':
CELLCLASS = CellSquare
else: # pragma: no cover
# Note that it the code should never get here, since if the
# cell type is not valid an exception will be raised in the
# '_calc_cell_positions' method which is called before this
# point.
raise RuntimeError("Invalid cell type: '{0}'".format(cell_type))
# Finally, create the cells at the specified positions (also
# rotated)
for index in range(num_cells):
cell_id = index + 1
c = CELLCLASS(cell_positions[index, 0], cell_radius, cell_id,
cell_positions[index, 1])
self._cells.append(c)
self._cell_pos.append(c.pos)
# Calculates the cluster radius.
#
# The radius of the cluster is defined as half the distance from
# one cluster to another. That is, if you plot multiple
# clusters and one circle positioned in each cluster center with
# radius equal to the cluster radius, the circles should be
# tangent to each other.
self._radius: float = Cluster._calc_cluster_radius(
num_cells, cell_radius)
# Calculates the cluster external radius.
self._external_radius: float = self._calc_cluster_external_radius()
# xxxxx Plot appearance xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
self._cell_id_fontsize: Optional[int] = None # If None -> use default
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxx radius property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# We re-implement the pos setter property here so that we can disable
# setting the radius of the cluster.
@property
def radius(self) -> float: # pragma: no cover
"""
Get the radius of the Cluster object.
Returns
-------
float
The radius of the Cluster object.
"""
return self._radius
@radius.setter
def radius(self, _: Any) -> None: # pylint: disable=R0201
"""
Disabled setter for the radius property defined in base class.
"""
raise AttributeError("can't set attribute")
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxx pos property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# We re-implement the pos setter property here so that we can disable
# setting the position of the cluster.
@property
def pos(self) -> complex:
"""
Get the Cluster position.
Returns
-------
complex
The Cluster position.
"""
return self._pos
@pos.setter
def pos(self, _: Any) -> None: # pylint: disable=R0201
"""
Disabled setter for the pos property defined in base class.
"""
raise AttributeError("can't set attribute")
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxx rotation property xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@property
def rotation(self) -> float:
"""
Get method for the rotation property.
Returns
-------
float
The shape rotation.
"""
return self._rotation
@rotation.setter
def rotation(self, _: Any) -> None: # pylint: disable=R0201
"""
Disabled setter for the rotation property defined in base class.
"""
raise AttributeError("can't set attribute")
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
def __repr__(self) -> str:
"""
Representation of a Cluster object.
Returns
-------
str
The string representation of the Cluster.
"""
msg = ("{0}(cell_radius={1},num_cells={2},pos={3},cluster_id={4},"
"cell_type={5},rotation={6})")
return msg.format(self.__class__.__name__, self._cell_radius,
self.num_cells, self.pos, self.cluster_id,
repr(self._cell_type), self._rotation)
@property
def cell_id_fontsize(self) -> Optional[int]:
"""
Get method for the cell_id_fontsize property.
The value of cell_id_fontsize only matters for plotting the
cluster.
Returns
-------
int | None
The font size that should be used for the cell IDs in the
plot.
"""
return self._cell_id_fontsize
@cell_id_fontsize.setter
def cell_id_fontsize(self, value: Optional[int] = None) -> None:
"""
Set method for the cell_id_fontsize property.
The value of cell_id_fontsize only matters for plotting the
cluster.
Parameters
----------
value : None | int
The font size used to plot the cell id. If it is None, the
default value in matplotlib will be used.
"""
self._cell_id_fontsize = value
for c in self._cells:
c.id_fontsize = value
# Property to get the cluster external radius
# The cluster class also has a external_radius parameter that
# corresponds to the radius of the smallest circle that can completely
# hold the cluster inside of it.
@property
def external_radius(self) -> float:
"""
Get the external_radius of the Cluster.
Returns
-------
float
The external_radius of the Cluster.
"""
return self._external_radius
@property
def num_users(self) -> int:
"""
Get method for the num_users property.
Returns
-------
int
The number of users in the Cluster.
"""
num_users = [cell.num_users for cell in self._cells]
return sum(num_users)
@property
def num_cells(self) -> int:
"""
Get method for the num_cells property.
Returns
-------
int
Number of cells in the Cluster.
"""
return len(self._cells)
@property
def cell_radius(self) -> float:
"""
Get method for the cell_radius property.
Returns
-------
float
The radius of the cells in the Cluster.
"""
return self._cell_radius
[docs] @staticmethod
def _calc_cell_height(radius: float) -> float:
"""
Calculates the cell height from the cell radius.
Parameters
----------
radius : float
The cell Radius.
Returns
-------
height : float
The cell height.
"""
return radius * math.sqrt(3.0) / 2.0
@property
def cell_height(self) -> float:
"""
Get method for the cell_height property.
Returns
-------
float
The height of the cells in the Cluster.
"""
return self._calc_cell_height(self.cell_radius)
def __iter__(self) -> Iterator[CellBase]:
"""Iterator for the cells in the cluster"""
return iter(self._cells)
[docs] def get_cell_by_id(self, cell_id: int) -> CellBase:
"""
Get the cell in the Cluster with the given `cell_id`.
Parameters
----------
cell_id : int
The ID of the desired cell.
Returns
-------
c : Cell
The desired cell.
"""
return self._cells[cell_id - 1]
[docs] def get_all_users(self) -> List["Node"]:
"""
Return all users in the cluster.
Returns
-------
all_users : list[Node]
A list with all users in the cluster.
"""
all_users = []
for cell in self._cells:
all_users.extend(cell.users)
return all_users
[docs] @staticmethod
def _get_ii_and_jj(num_cells: int) -> Tuple[int, int]:
"""
Valid cluster sizes are given by the formula
:math:`N = i^2+i*j+j^2`
where i and j are integer numbers and "N" is the number of cells in
the cluster. This static function returns the values "i" and "j"
for a given "N". The values are summarized below.
==== ===
i, j N
==== ===
1,0 01
1,1 03
2,0 04
2,1 07
3,1 13
3,2 19
==== ===
Parameters
----------
num_cells : int
Number of cells in the cluster.
Returns
-------
ii and jj : (int,int)
The ii and jj values corresponding to number of cells
'num_cells'.
Notes
-----
If `num_cells` is not in the table above then (0, 0) will be
returned.
"""
return Cluster._ii_and_jj.get(num_cells, (0, 0))
[docs] @staticmethod
def _calc_cell_positions(cell_radius: float,
num_cells: int,
cell_type: str = "simple",
rotation: Optional[float] = None) -> np.ndarray:
"""
Helper function used by the Cluster class.
The calc_cell_positions method calculates the position (and
rotation) of the 'num_cells' different cells, each with radius
equal to 'cell_radius', so that they properly fit in the cluster.
Parameters
----------
cell_radius : float
Radius of each cell in the cluster.
num_cells : int
Number of cells in the cluster.
cell_type : str
The type of the cell. It should be a string with one of the
possible values: 'simple', '3sec', or 'square'.
If it is 'simple' it means the standard hexagon shaped cell.
If '3sec' it means a 3 sectorized cell composed of 3 hexagons.
rotation : float | None, optional
Rotation of the cluster.
Returns
-------
cell_positions : np.ndarray
The first column of `cell_positions` has the positions of the
cells in a cluster with `num_cells` cells with radius
`cell_radius`. The second column has the rotation of each cell.
"""
if cell_type == 'simple':
cell_positions = Cluster._calc_cell_positions_hexagon(
cell_radius, num_cells, rotation)
elif cell_type == '3sec':
cell_positions = Cluster._calc_cell_positions_3sec(
cell_radius, num_cells, rotation)
elif cell_type == 'square':
cell_positions = Cluster._calc_cell_positions_square(
cell_radius, num_cells, rotation)
else:
raise RuntimeError("Invalid cell type: '{0}'".format(cell_type))
# xxxxx Possibly translate the positions of each cell xxxxxxxxxxxxx
# The coordinates of the cells calculated up to now consider the
# center of the first cell as the origin. However, we want the
# center of the cluster to be the origin. Therefore, lets calculate
# the central position of the cluster and then correct all
# coordinates to move the center of the cluster to the origin.
central_pos = np.sum(cell_positions, axis=0) / num_cells
# We correct only the first column, which is the position
cell_positions[:, 0] = cell_positions[:, 0] - central_pos[0]
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
return cell_positions
[docs] @staticmethod
def _calc_cell_positions_3sec(
cell_radius: float,
num_cells: int,
rotation: Optional[float] = None) -> np.ndarray:
"""
Helper function used by the Cluster class.
The _calc_cell_positions_3sec method calculates the position (and
rotation) of the 'num_cells' different cells, each with radius
equal to 'cell_radius', so that they properly fit in the cluster.
Parameters
----------
cell_radius : float
Radius of each cell in the cluster.
num_cells : int
Number of cells in the cluster.
rotation : float | None, optional
Rotation of the cluster.
Returns
-------
cell_positions : np.ndarray
The first column of `cell_positions` has the positions of the
cells in a cluster with `num_cells` cells with radius
`cell_radius`. The second column has the rotation of each cell.
"""
# In the end, the position of the cells of the Cell3Sec class in
# the cluster are exactly the same positions they would get if the
# were of the Cell (Hexagon shape) class.
return Cluster._calc_cell_positions_hexagon(cell_radius, num_cells,
rotation)
[docs] @staticmethod
def _calc_cell_positions_hexagon(
cell_radius: float,
num_cells: int,
rotation: Optional[float] = None) -> np.ndarray:
"""
Helper function used by the Cluster class.
The calc_cell_positions method calculates the position (and
rotation) of the 'num_cells' different cells, each with radius
equal to 'cell_radius', so that they properly fit in the cluster.
Parameters
----------
cell_radius : float
Radius of each cell in the cluster.
num_cells : int
Number of cells in the cluster.
rotation : float | None, optional
Rotation of the cluster.
Returns
-------
np.ndarray
The first column of `cell_positions` has the positions of the
cells in a cluster with `num_cells` cells with radius
`cell_radius`. The second column has the rotation of each cell.
"""
# Note that the Cluster._normalized_cell_positions dictionary store
# the positions of the cells for a cluster with radius equal to
# 1.0. Each key in the dictionary corresponds to a specific number
# f cells.
#
# If Cluster._normalized_cell_positions has no key with the
# value of 'num_cells' that means we still need to calculate it.
# Note, however, that this will be true only in the first time
# that this method is called and any subsequent call of this
# method for the same value of num_cells will avoid the
# calculations in the if block below.
if num_cells not in Cluster._normalized_cell_positions:
norm_radius = 1.0
# The first column in cell_positions has the cell positions
# (complex number) and the second column has the cell rotation
# (only the real part is considered)
cell_positions: np.ndarray = np.zeros([num_cells, 2],
dtype=complex)
cell_height = Cluster._calc_cell_height(norm_radius)
# xxxxx Get the positions of cells from 2 to 7 xxxxxxxxxxxxxxxx
# angles_first_ring -> 30:60:330 -> 30,90,150,210,270,330
angles_first_ring = np.linspace(np.pi / 6., 11. * np.pi / 6., 6)
max_value = min(num_cells, 7)
for index in range(1, max_value):
angle = angles_first_ring[index - 1]
cell_positions[index, 0] = cmath.rect(2 * cell_height, angle)
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxx Get the positions of cells from 8 to 19 xxxxxxxxxxxxxxx
# angles -> 0, 30, 60, ..., 330
angles = np.linspace(0, 11 * np.pi / 6., 12)
# For angle 0, the distance is 3*norm_radius, for angle 30 the
# distance is 4*cell_height, for angle 60 the distance is
# 3*norm_radius, for angle 90 the distance is 4*cell_height and
# the pattern continues.
dists = itertools.cycle([3 * norm_radius, 4 * cell_height])
# The distance alternates between 3*norm_radius and
# 4*cell_height.
for index, a, d in zip(range(7, num_cells), angles, dists):
cell_positions[index, 0] = cmath.rect(d, a)
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Store the normalized cell positions for a cluster with
# 'num_cells' cells for later reference.
Cluster._normalized_cell_positions[num_cells] = cell_positions
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# At this point we know that Cluster._normalized_cell_positions[
# num_cells] has the positions of the cells in a cluster with
# radius equal to 1.0 and with 'num_cells' cells. All we need to
# do is multiply that our desired cell_radius and then apply
# the 'rotation' (if there is any).
cell_positions = (Cluster._normalized_cell_positions[num_cells] *
cell_radius)
if rotation is not None:
# The cell positions calculated up to now do not consider
# rotation. Lets use the rotate function of the Shape class to
# rotate the coordinates.
cell_positions[:, 0] = shapes.Shape.calc_rotated_pos(
cell_positions[:, 0], rotation)
cell_positions[:, 1] = rotation
return cell_positions
# noinspection PyUnresolvedReferences
[docs] @staticmethod
def _calc_cell_positions_square(
side_length: float,
num_cells: int,
rotation: Optional[float] = None) -> np.ndarray:
"""
Helper function used by the Cluster class.
The calc_cell_positions method calculates the position (and
rotation) of the 'num_cells' different cells, each with side
equal to 'cell_radius', so that they properly fit in the cluster.
Parameters
----------
side_length : float
The side length of each square cell in the cluster.
num_cells : int
Number of cells in the cluster.
rotation : float | None, optional
Rotation of the cluster.
Returns
-------
cell_positions : np.ndarray
The first column of `cell_positions` has the positions of the
cells in a cluster with `num_cells` cells with radius
`cell_radius`. The second column has the rotation of each cell.
"""
cell_positions = np.zeros([num_cells, 2], dtype=complex)
sqrt_num_cells = int(math.sqrt(num_cells))
if sqrt_num_cells**2 != num_cells:
raise ValueError("num_cells must be a perfect square number")
int_positions = np.unravel_index(np.arange(num_cells),
(sqrt_num_cells, sqrt_num_cells))
cell_positions[:, 0] = (
side_length *
(int_positions[1] + 1j * int_positions[0][::-1] - 0.5 - 0.5j))
if rotation is not None:
# The cell positions calculated up to now do not consider
# rotation. Lets use the rotate function of the Shape class to
# rotate the coordinates.
cell_positions[:, 0] = shapes.Shape.calc_rotated_pos(
cell_positions[:, 0], rotation)
cell_positions[:, 1] = rotation
return cell_positions
[docs] @staticmethod
def _calc_cluster_radius(num_cells: int, cell_radius: float) -> float:
"""
Calculates the "cluster radius" for a cluster with "num_cells"
cells, each cell with radius equal to "cell_radius". The cluster
"radius" is equivalent to half the distance between two clusters
when they are in a Grid.
Parameters
----------
num_cells : int
Number of cells in the cluster.
cell_radius : float
Radius of each cell in the cluster.
Returns
-------
cluster_radius : float
The radius of a cluster with `num_cells` cells with radius
`cell_radius`.
Notes
-----
The cluster "radius" is equivalent to half the distance between two
clusters.
"""
cell_height = Cluster._calc_cell_height(cell_radius)
# In the Rappaport book we have
# the formula
# N = i^2+i*j+j^2
# where N is the number of cells in a cluster and "i" and "j"
# are two integer numbers. For each valid value of N we set the
# "ii" and "jj" variables appropriately.
(ii, jj) = Cluster._get_ii_and_jj(num_cells)
# Considering one cluster with center located at the origin, we can
# calculate the center of another cluster using the "ii" and "jj"
# variables (see the Rappaport book).
other_cluster_pos = (cell_height * ((jj * 0.5) +
(1j * jj * math.sqrt(3.) / 2.)) +
cell_height * ii)
# Now we can calculate the radius simple getting half the distance
# from the origin to the center of the other cluster.
radius = abs(other_cluster_pos)
return radius
[docs] def _calc_cluster_external_radius(self) -> float:
"""
Calculates the cluster external radius.
The cluster external radius is equal to the radius of the smallest
circle (located at the center of the cluster) that contains the
cluster. This circle should touch only the most external vertexes
of the cells in the cluster.
Get the vertex positions of the last cell.
Returns
-------
external_radius : float
The cluster external radius.
"""
vertex_positions = self._cells[-1].vertices
dists = vertex_positions - self.pos
external_radius = np.max(np.abs(dists))
return cast(float, external_radius)
[docs] def _get_outer_vertexes(self, vertexes: np.ndarray, central_pos: complex,
distance: float) -> np.ndarray:
"""
Filter out vertexes closer to the shape center them `distance`.
This is a helper method used in the _get_vertex_positions method.
Parameters
----------
vertexes : np.ndarray
The outer vertexes of the cluster.
central_pos : complex
Central position of the shape.
distance : float
A minimum distance. Any vertex that is closer to the shape
center then this distance will be removed.
Returns
-------
outer_vertexes : np.ndarray
The cluster outer vertexes.
"""
def f(x: np.ndarray) -> np.ndarray:
"""
Filter function. Returns True for vertexes which are closer
to the shape center than `distance`.
Parameters
----------
x : np.ndarray
"""
return np.abs(x - central_pos) > distance
vertexes = vertexes[f(vertexes)]
# Remove duplicates
# Float equality test (used implicitly by 'set' to remove
# duplicates) is not trustable. We lower the precision to make
# it more trustable but maybe calculating the cluster vertexes
# like this is not the best way.
vertexes = frozenset(vertexes.round(12))
vertexes = np.fromiter(vertexes, dtype=complex)
# In order to use these vertices for plotting, we need them to be
# in order (lowest angle to highest)
vertexes = vertexes[np.argsort(np.angle(vertexes - self.pos))]
return vertexes
[docs] def _get_vertex_positions(self) -> np.ndarray:
"""
Get the vertex positions of the cluster borders.
Returns
-------
vertex_positions : np.ndarray
The vertex positions of the cluster borders.
Notes
-----
This is only valid for cluster sizes from 1 to 19.
"""
cell_radius = self._cells[0].radius
if self.num_cells == 1:
# If the cluster has a single cell, the cluster vertexes are
# the same as the ones from this single cell. We don't have to
# do anything else.
return self._cells[0].vertices
if self.num_cells < 4: # From 2 to 3
start_index = 0
distance = 0.2 * cell_radius
elif self.num_cells < 7: # From 4 to 6
start_index = 0
distance = 1.05 * cell_radius
elif self.num_cells < 11: # From 7 to 10
start_index = 1
distance = 1.8 * cell_radius
elif self.num_cells < 14: # From 11 to 13
start_index = 4
distance = 2.15 * cell_radius
elif self.num_cells < 16: # From 14 to 15
start_index = 7
distance = 2.45 * cell_radius
elif self.num_cells < 20: # From 16 to 19
start_index = 7
distance = 2.80 * cell_radius
else:
# Invalid number of cells per cluster
return np.array([])
all_vertexes = np.array(
[cell.vertices for cell in self._cells[start_index:]]).flatten()
return self._get_outer_vertexes(all_vertexes, self.pos, distance)
# Note: The _get_vertex_positions method which should return the
# shape vertexes without translation and rotation and the vertexes
# property from the Shape class would add the translation and
# rotation. However, the _get_vertex_positions method in the Cluster
# class return vertices's that already contains the translation and
# rotation. Therefore, we overwrite the property here to return the
# output of _get_vertex_positions.
vertices = property(_get_vertex_positions)
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the cluster.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cluster will be plotted. If not provided,
a new figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
# self.fill_face_bool = False
# self.fill_color = 'r'
# self.fill_opacity = 0.1
for cell in self._cells:
if self.fill_face_bool is True:
cell.fill_face_bool = True
cell.fill_color = self.fill_color
cell.fill_opacity = self.fill_opacity
else:
cell.fill_face_bool = False
cell.plot(ax)
for wrapped_cell in self._wrapped_cells.values():
if self.fill_face_bool is True:
wrapped_cell.fill_face_bool = True
wrapped_cell.fill_opacity = self.fill_opacity
# wrapped_cell.fill_color = self.fill_color
# wrapped_cell.fill_opacity = self.fill_opacity
else:
wrapped_cell.fill_face_bool = False
wrapped_cell.plot(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs] def plot_border(self,
ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot only the border of the Cluster.
Only work's for cluster sizes that can calculate the cluster
vertices, such as cluster with 1, 7 or 19 cells.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the cluster will be plotted. If not provided,
a new figure (and axis) will be created.
"""
if len(self.vertices) != 0:
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
_, ax = plt.subplots(figsize=self.figsize)
stand_alone_plot = True
polygon_edges = patches.Polygon(
shapes.from_complex_array_to_real_matrix(self.vertices),
True,
facecolor='none', # No face
alpha=1,
linewidth=2)
ax.add_patch(polygon_edges)
if stand_alone_plot is True:
ax.plot()
plt.show()
else:
ax.autoscale_view(False, True, True)
[docs] def add_random_users(self,
cell_ids: Optional[IntOrIntIterable] = None,
num_users: IntOrIntIterable = 1,
user_color: Optional[StrOrStrIterable] = None,
min_dist_ratio: FloatOrFloatIterable = 0.0) -> None:
"""
Adds one or more users to the Cells with the specified cell IDs
(the first cell has an ID equal to 1.).
Parameters
----------
cell_ids : int | list[int] | np.ndarray
IDs of the cells in the Cluster for which users will be
added. The first cell has an ID equal to 1 and `cell_ids` may
be an iterable with the IDs of several cells. If not provided,
all cells will be assumed.
num_users : int | list[int] | np.ndarray
Number of users to be added to each cell.
user_color : str | list[str], optional
Color of the user's marker.
min_dist_ratio : float, list[float], optional
Minimum allowed (relative) distance between the cell center and
the generated random user. See Cell.add_random_user method for
details.
Notes
-----
If `cell_ids` is an iterable then the other attributes (num_users,
user_color and min_dist_ratio) may also be iterable with the same
length of cell_ids in order to specifying individual values for
each cell ID.
"""
if cell_ids is None:
cell_ids = range(1, self.num_cells + 1)
if isinstance(cell_ids, Iterable):
num_users_iterable = num_users if isinstance(
num_users, Iterable) else itertools.repeat(num_users)
user_color_iterable = itertools.repeat(user_color) if (isinstance(
user_color, str) or user_color is None) else user_color
min_dist_ratio_iterable = min_dist_ratio if isinstance(
min_dist_ratio, Iterable) else itertools.repeat(min_dist_ratio)
all_data = zip(cell_ids, num_users_iterable, user_color_iterable,
min_dist_ratio_iterable)
for data in all_data:
self.add_random_users(*data)
else:
assert (isinstance(num_users, int))
assert (isinstance(min_dist_ratio, float))
assert (user_color is None or isinstance(user_color, str))
for _ in range(num_users):
# Note that here cell_ids will be a single value, as well
# as user_color and min_dist_ratio
self.get_cell_by_id(cell_ids).add_random_user(
user_color, min_dist_ratio)
[docs] def add_border_users(
self,
cell_ids: IntOrIntIterable,
angles: FloatOrFloatIterable,
ratios: FloatOrFloatIterable = 1.0,
user_color: Optional[StrOrStrIterable] = None) -> None:
"""
Add users to all the cells indicated by `cell_indexes` at the
specified angle(s) (in degrees) and ratio (relative distance
from the center to the border of the cell).
Parameters
----------
cell_ids : int | list[int] | np.ndarray
IDs of the cells in the Cluster for which users will be
added. The first cell has an ID equal to 1 and `cell_ids` may
be an iterable with the IDs of several cells.
angles : float | list[float] | np.ndarray
Angles (in degrees)
ratios : float | list[float]
Ratios (from 0 to 1)
user_color : str | list[str]
Color of the user's marker.
Examples
--------
>>> cluster = Cluster(cell_radius=1.0, num_cells=3)
>>> # Add a single user in the angle of 30 degrees with a ration of
>>> # 0.9 to the first cell in the cluster
>>> cluster.add_border_users(1, 30, 0.9)
>>>
>>> # Add 3 users at the angles of 0, 95 and 185 degrees to the
>>> # second cell of the cluster
>>> cluster.add_border_users(2, [0, 95, 185], 0.9, 'b')
>>>
>>> # Add one user in each cell at the angle of 10 degrees
>>> cluster.add_border_users([1, 2, 3], 10, 0.9, 'g')
>>>
>>> # Add a user in each cell at different angles per cell
>>> cluster.add_border_users([1, 2, 3], [90, 150, 190], 0.9, 'y')
>>>
>>> # Add multiple users to multiple cells at different angles
>>> cluster.add_border_users(\
[1, 2, 3], [[180, 270], [-30], [60, 120]], 0.9, 'k')
"""
# If cell_ids is not an iterable, that is, cell_ids is a single
# number, then we are simply calling the add_border_users method of
# the specified cell
if not isinstance(cell_ids, Iterable):
self.get_cell_by_id(cell_ids).add_border_user(
angles, ratios, user_color)
else:
# If angles is not an iterable, then lets repeat the same value
# for all specified cells by using itertools.repeat to make
# angles an iterable.
angles_iter = angles if isinstance(
angles, Iterable) else itertools.repeat(angles)
# If ratios is not an iterable, then lets repeat the same value
# for all specified cells by using itertools.repeat to make
# ratios an iterable.
ratios_iter = ratios if isinstance(
ratios, Iterable) else itertools.repeat(ratios)
# If user_color is not an iterable of strings, then lets repeat
# the same value for all specified cells by using
# itertools.repeat to make user_color an iterable of strings.
user_color_iter = itertools.repeat(user_color) if (isinstance(
user_color, str) or user_color is None) else user_color
all_data = zip(cell_ids, angles_iter, ratios_iter, user_color_iter)
for cell_id, angle, ratio, color in all_data:
self.get_cell_by_id(cell_id).add_border_user(
angle, ratio, color)
[docs] def delete_all_users(self,
cell_id: Optional[IntOrIntIterable] = None) -> None:
"""
Remove all users from one or more cells.
If cell_id is an integer > 0, only the users from the cell whose
index is `cell_id` will be removed. If cell_id is an iterable, then
the users of cells pointed by it will be removed. If cell_id is
`None` or not specified, then the users of all cells will be
removed.
Parameters
----------
cell_id : int | list[int], optional
ID(s) of the cells from which users will be removed. If equal
to None, all the users from all cells will be removed.
"""
if isinstance(cell_id, Iterable):
for i in cell_id:
self.get_cell_by_id(i).delete_all_users()
elif cell_id is None:
for cell in self._cells:
cell.delete_all_users()
else:
self.get_cell_by_id(cell_id).delete_all_users()
[docs] def create_wrap_around_cells(
self, # pragma: no cover
include_users_bool: bool = False) -> None:
"""
This function will create the wrapped cells, as well as the
wrap info data.
Parameters
----------
include_users_bool : bool
Set to True if the users of the original cells should appear in
the wrapped version.
"""
positions = Cluster._calc_cell_positions(self.cell_radius,
self.num_cells,
self._cell_type,
self.rotation)
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# noinspection PyUnresolvedReferences
def get_pos_from_relative(rel_center_idx: int,
rel_cell_idx: int) -> complex:
"""
Parameters
----------
rel_center_idx : int
Index (starting from 1) of the cell that should be
considered as the center of the 7-Cell cluster.
rel_cell_idx : int
Index (starting from 1) of the desired cell in the 7-Cell
cluster.
Returns
-------
complex
"""
return cast(complex, (positions[rel_center_idx - 1, 0] +
positions[rel_cell_idx - 1, 0] + self.pos))
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# TODO: Maybe implement for other cluster sizes
if self.num_cells == 19:
# Reset the variable with the distance between cells, since we
# will create new (wrapped) cells.
self._cell_pos_diffs = None
# In order to explain the sequences in the for loop below
# let's take as an example the first value of each sequence,
# that is, (17, 7, 13). That means that we will create a
# wrapped cell for cell 13 and it will be located at a
# position corresponding to the position of cell 7 in a
# 7-cell cluster centered at the position of the cell 17 in
# our 19-cell cluster.
for rel_center, rel_cell, wrapped_id in zip(
# Relative centers
[
17, 18, 19, 8, 8, 9, 9, 10, 11, 12, 13, 13, 14, 15, 15, 16,
17, 17, 12, 13, 13, 13, 14, 15, 15, 15, 15, 16, 17, 17, 17,
18, 19, 19, 19, 8, 9, 9, 9, 10, 11, 11
],
# Relative positions regarding the relative center
[
7, 7, 7, 7, 2, 2, 3, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 11,
11, 12, 13, 13, 13, 14, 15, 16, 16, 16, 17, 18, 18, 18, 19,
8, 8, 8, 9, 10, 10, 10, 11
],
# ID of the wrapped cell
[
13, 12, 11, 15, 14, 13, 17, 16, 15, 19, 18, 17, 9, 8, 19,
11, 10, 9, 8, 7, 6, 16, 10, 2, 7, 18, 12, 3, 2, 8, 14, 4,
3, 10, 16, 5, 4, 12, 18, 6, 5, 14
]):
pos = get_pos_from_relative(rel_center, rel_cell)
w = CellWrap(pos, self.get_cell_by_id(wrapped_id),
include_users_bool)
self._wrapped_cells['wrap{0}_{1}:{2}'.format(
wrapped_id, rel_center, rel_cell)] \
= w
self._cell_pos[wrapped_id - 1] = np.append(
self._cell_pos[wrapped_id - 1], w.pos)
else:
msg = ("Wrap around not implemented for a cluster with {0} "
"cells.")
raise RuntimeError(msg.format(self.num_cells))
[docs] def calc_dists_between_cells(self) -> np.ndarray:
"""
This method calculates the distance between any two cells in the
cluster possibly considering wrap around.
If the `create_wrap_around_cells` method was called before this
one, then when calculating the distance between two cells if the
distance between a given cell and the wrapped version of another
cell is smaller then the distance to that other cell it will be
sued instead.
For instance, the
Returns
-------
dists : np.ndarray
A matrix with the distance from each cell to each other cell in
the cluster.
"""
if self._cell_pos_diffs is None:
diffs = np.empty([self.num_cells, self.num_cells], dtype=complex)
pos = [np.array(p) for p in self._cell_pos]
for i, c in enumerate(self._cells):
a = np.abs(c.pos - pos)
indexes = map(np.argmin, a)
for j, idx in enumerate(indexes):
diffs[i, j] = (c.pos - pos[j][idx])
self._cell_pos_diffs = diffs
return self._cell_pos_diffs
# This method was originally created to calculate the distance between
# each user and each cell before wrap around was implemented.
[docs] def calc_dist_all_users_to_each_cell_no_wrap_around(self) -> np.ndarray:
"""
Returns a matrix with the distance from each user to each cell
center.
This matrix is suitable to later calculate the path loss from each
base station to each mobile station.
Because usually the base station is the transmitter and the mobile
station is the receiver the matrix is such that each column
corresponds to a different base station and each row corresponds to
a different mobile station.
Returns
-------
all_dists : np.ndarray
Distance from each cell center to each user.
Notes
-----
There is no explicit indication from which cell each user came
from. However, in a case, for instance, where there are 3 cells in
the cluster with 2, 2 and 3 users in each of them, respectively,
then the first 2 rows correspond to the users in the first cell,
the following 2 rows correspond to the users in the second cell and
the last three rows correspond to the users in the third cell.
"""
all_users = self.get_all_users()
# We use the ndmin=2 option so that the array has two dimensions
# instead of just one
all_users_pos = np.array([x.pos for x in all_users], ndmin=2)
all_cells_pos = np.array([x.pos for x in self._cells], ndmin=2)
# Using broadcast we can calculate all distances in one go without
# any for loop. -> dists[user_index, cell_index]
dists = np.abs(all_users_pos.T - all_cells_pos)
return dists
[docs] def calc_dist_all_users_to_each_cell(self) -> np.ndarray:
"""
Returns a matrix with the distance from each user to each cell
center.
This matrix is suitable to later calculate the path loss from each
base station to each mobile station.
Because usually the base station is the transmitter and the mobile
station is the receiver the matrix is such that each column
corresponds to a different base station and each row corresponds to
a different mobile station.
Returns
-------
all_dists : np.ndarray
Distance from each cell center to each user.
Notes
-----
There is no explicit indication from which cell each user came
from. However, in a case, for instance, where there are 3 cells in
the cluster with 2, 2 and 3 users in each of them, respectively,
then the first 2 rows correspond to the users in the first cell,
the following 2 rows correspond to the users in the second cell and
the last three rows correspond to the users in the third cell.
"""
all_users = self.get_all_users()
# Array with the position of all users in the cluster (no matter
# which cell they are assigned to)
all_users_pos = np.array([u.pos for u in all_users])
# Array with the position of each cell in the cluster
all_cells_pos = np.array([c.pos for c in self])
# Calculate the distance from each user to each cell
all_dists = np.abs(all_users_pos[:, np.newaxis] - all_cells_pos)
return all_dists
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxx Grid Class xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[docs]class Grid:
"""
Class representing a grid of clusters of cells or a single cluster with
its surrounding cells.
Valid cluster sizes are given by the formula
:math:`N = i^2+i*j+j^2`
where i and j are integer numbers. The values allowed in the Cluster
are summarized below with the corresponding values of i and j.
==== ===
i, j N
==== ===
1,0 01
1,1 03
2,0 04
2,1 07
3,1 13
3,2 19
==== ===
"""
# Available colors for the clusters. These colors must be understood by
# the plot library
_colors = ['b', 'g', 'r', 'c', 'm', 'y', 'k', 'w']
def __init__(self) -> None:
self._cell_radius: float = 0.0
self._num_cells: int = 0
# A list with the clusters in the grid
self._clusters: List[Cluster] = []
[docs] def get_cluster_from_index(self, index: int) -> Cluster:
"""
Return the cluster object with index `index` in the Grid.
Parameters
----------
index : int
The index of the desirable cluster.
Returns
-------
Cluster
The desired cluster in the Grid.
"""
return self._clusters[index]
@property
def num_clusters(self) -> int:
"""
Get method for the num_clusters property.
Returns
-------
int
The number of clusters in teh grid.
"""
return len(self._clusters)
def __iter__(self) -> Iterator[Cluster]:
"""Iterator for the clusters in the Grid
"""
return iter(self._clusters)
[docs] def clear(self) -> None:
"""Clear everything in the grid.
"""
self._clusters = []
self._cell_radius = 0.0
self._num_cells = 0
[docs] def create_clusters(self, num_clusters: int, num_cells: int,
cell_radius: float) -> None:
"""
Create the clusters in the grid.
Parameters
----------
num_clusters : int
Number of clusters to be created in the grid.
num_cells : int
Number of cells per clusters.
cell_radius : float
The radius of each cell.
"""
self.clear()
if num_cells not in frozenset([2, 3, 7]):
msg = ("The Grid class does not implement the case of clusters"
" with {0} cells")
raise ValueError(msg.format(num_cells))
self._cell_radius = cell_radius
self._num_cells = num_cells
options = {
2: self._calc_cluster_pos2,
3: self._calc_cluster_pos3,
7: self._calc_cluster_pos7
}
# Method to calculate the central position of the next cluster
calc_pos = options[num_cells]
for _ in range(num_clusters):
central_pos = calc_pos()
# cell_radius, num_cells, pos=0 + 0j, cluster_id=None
new_cluster = Cluster(cell_radius, num_cells, central_pos,
self.num_clusters + 1)
new_cluster.fill_face_bool = True
new_cluster.fill_color = Grid._colors[self.num_clusters]
new_cluster.fill_opacity = 0.3
self._clusters.append(new_cluster)
[docs] def _calc_cluster_pos2(self) -> complex:
"""
Calculates the central position of clusters with 2 cells.
Returns
-------
central_pos : complex
Central position of the next cluster to be added to the Grid.
Notes
-----
The returned central position will depend on how many clusters were
already added to the grid.
"""
cluster_index = self.num_clusters + 1
if cluster_index == 1:
return 0 + 0j
if cluster_index == 2:
angle = np.pi / 3.0
length = math.sqrt(3) * self._cell_radius
return length * cmath.exp(1j * angle)
msg = ("For the two cells per cluster case only two clusters"
" may be used")
raise ValueError(msg)
[docs] def _calc_cluster_pos3(self) -> complex:
"""
Calculates the central position of clusters with 3 cells.
Returns
-------
central_pos : complex
Central position of the next cluster to be added to the Grid.
Notes
-----
The returned central position will depend on how many clusters were
already added to the grid.
"""
cluster_index = self.num_clusters + 1
if cluster_index == 1:
return 0 + 0j
angle = (np.pi / 3.) * (cluster_index - 1) - (np.pi / 6.)
length = 3 * self._cell_radius
return length * cmath.exp(1j * angle)
[docs] def _calc_cluster_pos7(self) -> complex:
"""
Calculates the central position of clusters with 7 cells.
Returns
-------
central_pos : complex
Central position of the next cluster to be added to the Grid.
Notes
-----
The returned central position will depend on how many clusters were
already added to the grid.
"""
cluster_index = self.num_clusters + 1
if cluster_index == 1:
return 0.0 + 0.0j
angle = math.atan(math.sqrt(3.) / 5.)
angle += (math.pi / 3) * (cluster_index - 2)
length = math.sqrt(21) * self._cell_radius
return length * cmath.exp(1j * angle)
[docs] def plot(self, ax: Optional[Any] = None) -> None: # pragma: no cover
"""
Plot the grid of clusters.
Parameters
----------
ax : A matplotlib axis, optional
The axis where the grid will be plotted. If not provided, a new
figure (and axis) will be created.
"""
stand_alone_plot = False
if ax is None:
# This is a stand alone plot. Lets create a new axes.
# noinspection PyUnresolvedReferences
_, ax = plt.subplots(figsize=(8, 8))
stand_alone_plot = True
for cluster in self._clusters:
cluster.plot(ax)
if stand_alone_plot is True:
ax.plot()
plt.show()
# This method is the same in the Shape class
# This method is the same in the Shape class
[docs] def _repr_png_(self) -> Any: # pragma: no cover
"""
Return the PNG representation of the shape.
"""
return self._repr_some_format_('png')
# This method is the same in the Shape class
[docs] def _repr_svg_(self) -> Any: # pragma: no cover
"""
Return the SVG representation of the shape.
"""
return self._repr_some_format_('svg')
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx