Repository: point-cloud-radar/bird-cloud-gnn (language: python)

Issue:
Add fake dataset generation



Discussion / hints:

Fix patch:
diff --git a/bird_cloud_gnn/fake.py b/bird_cloud_gnn/fake.py
new file mode 100644
index 0000000..29128f5
--- /dev/null
+++ b/bird_cloud_gnn/fake.py
@@ -0,0 +1,108 @@
+"""Generation of fake point cloud radar data."""
+
+from math import ceil
+import numpy as np
+import pandas as pd
+from scipy.spatial import KDTree
+
+
+# pylint: disable=too-many-arguments, too-many-locals
+def generate_data(
+    filename=None,
+    num_points=2**13,
+    min_range=50.0,
+    max_range=300_000.0,
+    azimuth_skip=2.0,
+    elevations=np.array([0.3, 0.8, 1.2, 2, 2.8, 4.5, 6, 8, 10, 12, 15, 20,
+                         25]),
+    add_na=False,
+    z_position=343.0,
+    radius_influence=300.0,
+):
+    """Generate fake point cloud radar data
+
+    Ranges are generated from exponential decay, azimuth are
+
+    Args:
+        filename (str, optional): Filename to save the data. Use None to ignore.
+        num_points (int, optional): Number of points. Defaults to 2**13.
+        max_range (float, optional): Maximum generated range. Defaults to 300_000.0.
+        azimuth_skip (float, optional): azimuth is generated from 0.5 to 355.5 with the given
+            skip. Defaults to 2.0.
+        elevations (array of floats, optional): List of elevations. Defaults to
+            np.array([0.3, 0.8, 1.2, 2, 2.8, 4.5, 6, 8, 10, 12, 15, 20, 25]).
+        add_na (bool, optional): Whether to add missing data. Defaults to False.
+        z_position (float, optional): z position of the radar. Used for offsetting z after
+            converting from polar to cartesian. Defaults to 343.0.
+        radius_influence (float, optional): Radius used to compute the number of neighbours
+            used internally for predicting the target class. Defaults to 300.0.
+
+    Returns:
+        pandas.DataFrames: Generated data. It was also saved to `filename` if that argument was
+            passed. The columns are `range`, `azimuth` and `elevation` for the polar coordinates,
+            `x`, `y`, and `z` for the cartesian coordinates, `useless_feature`, `feat1`, `feat2`,
+            `feat3` for the numerical features, and `class` for the target.
+    """
+
+    point_cloud = pd.DataFrame({
+        'range':
+        min_range + np.exp(-5 * np.random.rand(num_points)) * (max_range - min_range),
+        'azimuth':
+        np.tile(
+            np.arange(0, 360, azimuth_skip) + 0.5,
+            ceil(num_points / 360 * azimuth_skip))[0:num_points],
+        'elevation':
+        sorted(
+            np.tile(elevations,
+                    ceil(num_points / len(elevations)))[0:num_points]),
+        'useless_feature':
+        np.random.randn(num_points),
+    })
+
+    c_elevation, s_elevation = np.cos(
+        np.pi / 180 * point_cloud.elevation), np.sin(np.pi / 180 *
+                                                     point_cloud.elevation)
+    c_azimuth, s_azimuth = np.cos(np.pi / 180 * point_cloud.azimuth), np.sin(
+        np.pi / 180 * point_cloud.azimuth)
+    point_cloud['x'] = point_cloud.range * c_elevation * s_azimuth
+    point_cloud['y'] = point_cloud.range * c_elevation * c_azimuth
+    point_cloud['z'] = point_cloud.range * s_elevation + z_position
+
+    xyz = ['x', 'y', 'z']
+    tree = KDTree(point_cloud.loc[:, xyz])
+
+    # Pretty much random choices below
+    def sigmoid(value):
+        return 1 / (1 + np.exp(-value))
+
+    point_cloud['feat1'] = sigmoid(
+        (point_cloud.x + point_cloud.y - point_cloud.z) / max_range)
+    point_cloud['feat2'] = sigmoid(
+        (point_cloud.z - point_cloud.z.mean()) / point_cloud.z.std())
+    point_cloud['feat3'] = (np.cos(
+        np.exp(-0.3 * (point_cloud.x - 1)**2 - 0.2 *
+               (point_cloud.y + 0.3)**2)) + 1) / 2
+    hidden1 = (point_cloud.feat1 + point_cloud.feat2**2) / point_cloud.feat3
+    hidden1 = (hidden1 - np.mean(hidden1)) / np.std(hidden1)
+    hidden2 = np.log(1 + point_cloud.feat1**2) - np.sin(
+        4 * np.pi * point_cloud.feat2)
+    hidden2 = (hidden2 - np.mean(hidden2)) / np.std(hidden2)
+
+    point_cloud['neighbours'] = point_cloud.apply(lambda row: len(
+        tree.query_ball_point(row[['x', 'y', 'z']], radius_influence)),
+                                                  axis=1)
+    aux = (point_cloud['neighbours'] > 5).astype('int32')
+    point_cloud['class'] = np.round(
+        sigmoid(0.5 * hidden1 + 0.2 * hidden2 + 3 * aux) +
+        np.random.randn(num_points) * 0.1)
+
+    if add_na:
+        point_cloud.loc[np.random.randint(0, num_points, num_points // 100),
+                        'feat2'] = None
+        point_cloud.loc[np.random.randint(0, num_points, num_points // 20),
+                        'feat3'] = None
+
+    if filename is not None:
+        point_cloud.to_csv(filename, index=None)
+
+    return point_cloud
diff --git a/setup.cfg b/setup.cfg
index 798dded..29cdab3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -36,6 +36,9 @@ python_requires = >=3.7
 include_package_data = True
 packages = find:
 install_requires =
+    pandas
+    numpy
+    scipy
 
 [options.data_files]
 # This section requires setuptools>=40.6.0


Test patch:
diff --git a/tests/test_fake.py b/tests/test_fake.py
new file mode 100644
index 0000000..aa48333
--- /dev/null
+++ b/tests/test_fake.py
@@ -0,0 +1,43 @@
+"""Tests for bird_cloud_gnn.fake module."""
+
+import numpy as np
+import pandas as pd
+from bird_cloud_gnn.fake import generate_data
+
+
+def test_generate_data(tmp_path):
+    """Tests basic information about the generated data
+    """
+    filename = tmp_path / "fake_data.csv"
+    df = generate_data(
+        filename=filename,
+        num_points=2**4,
+        min_range=0.1,
+        max_range=2.3,
+        azimuth_skip=90.0,
+        elevations=[0, 30, 60],
+    )
+    assert df.shape == (2**4, 12)
+    assert df.range.min() >= 0.1
+    assert df.range.max() <= 2.3
+    assert sorted(df.azimuth.unique()) == [0.5, 90.5, 180.5, 270.5]
+    assert sorted(df.elevation.unique()) == [0, 30, 60]
+    assert (df.columns == [
+        'range', 'azimuth', 'elevation', 'useless_feature', 'x', 'y', 'z',
+        'feat1', 'feat2', 'feat3', 'neighbours', 'class'
+    ]).all()
+    assert df.notna().all(axis=None)
+    df2 = pd.read_csv(filename)
+    for col in df.columns:
+        if not df2[col].eq(df[col]).all():
+            print(col)
+            print(df[col] - df2[col])
+
+    assert (np.abs(df2 - df) < 1e-12).all(
+        axis=None)  # Round off error might occur saving the file!
+
+    df = generate_data(
+        filename=None,
+        add_na=True,
+    )
+    assert df.isna().any(axis=None)