diff --git a/sleap_roots/lengths.py b/sleap_roots/lengths.py index ac258fc..a46c30e 100644 --- a/sleap_roots/lengths.py +++ b/sleap_roots/lengths.py @@ -1,7 +1,6 @@ """Get length-related traits.""" import numpy as np -from sleap_roots.bases import get_base_tip_dist -from typing import Optional +from typing import Union def get_max_length_pts(pts: np.ndarray) -> np.ndarray: @@ -113,57 +112,50 @@ def get_root_lengths_max(pts: np.ndarray) -> np.ndarray: def get_grav_index( - primary_length: Optional[float] = None, - primary_base_tip_dist: Optional[float] = None, - pts: Optional[np.ndarray] = None, -) -> float: - """Calculate the gravitropism index of a primary root. + lengths: Union[float, np.ndarray], base_tip_dists: Union[float, np.ndarray] +) -> Union[float, np.ndarray]: + """Calculate the gravitropism index of a root. The gravitropism index quantifies the curviness of the root's growth. A higher gravitropism index indicates a curvier root (less responsive to gravity), while a lower index indicates a straighter root (more responsive to gravity). The index is - computed as the difference between the maximum primary root length and straight-line - distance from the base to the tip of the primary root, normalized by the root length. + computed as the difference between the maximum root length and straight-line + distance from the base to the tip of the root, normalized by the root length. Args: - primary_length: Maximum length of the primary root. Used if `pts` is not - provided. - primary_base_tip_dist: The straight-line distance from the base to the tip of - the primary root. Used if `pts` is not provided. - pts: Landmarks of the primary root of shape `(instances, nodes, 2)`. If - provided, `primary_length` and `primary_base_tip_dist` are ignored. + lengths: Maximum length of the root(s). Can be a scalar or a 1D numpy array + of shape `(instances,)`. + base_tip_dists: The straight-line distance from the base to the tip of the + root(s). Can be a scalar or a 1D numpy array of shape `(instances,)`. Returns: - float: Gravitropism index of the primary root, quantifying its curviness. + Gravitropism index of the root(s), quantifying its/their curviness. Will be a + scalar if input is scalar, or a 1D numpy array of shape `(instances,)` + otherwise. """ - # Use provided scalar values if available - if primary_length is not None and primary_base_tip_dist is not None: - max_primary_length = primary_length - max_base_tip_distance = primary_base_tip_dist - - # Use provided pts array to compute required values if available - elif pts is not None: - if np.isnan(pts).all(): - return np.nan - primary_length_max = get_root_lengths_max(pts=pts) - primary_base_tip_dist = get_base_tip_dist(pts=pts) - max_primary_length = np.nanmax(primary_length_max) - max_base_tip_distance = np.nanmax(primary_base_tip_dist) - + # Check if the input is scalar or array + is_scalar_input = np.isscalar(lengths) and np.isscalar(base_tip_dists) + + # Convert scalars to numpy arrays for uniform handling + lengths = np.atleast_1d(np.asarray(lengths, dtype=float)) + base_tip_dists = np.atleast_1d(np.asarray(base_tip_dists, dtype=float)) + + # Check for shape mismatch + if lengths.shape != base_tip_dists.shape: + raise ValueError("The shapes of lengths and base_tip_dists must match.") + + # Calculate the gravitropism index where possible + grav_index = np.where( + (~np.isnan(lengths)) + & (~np.isnan(base_tip_dists)) + & (lengths > 0) + & (lengths >= base_tip_dists), + (lengths - base_tip_dists) / lengths, + np.nan, + ) + + # Return scalar or array based on the input type + if is_scalar_input: + return grav_index.item() else: - raise ValueError( - "Either both primary_length and primary_base_tip_dist, or pts" - "must be provided." - ) - - # Check for invalid values (NaN or zero lengths) - if ( - np.isnan(max_primary_length) - or np.isnan(max_base_tip_distance) - or max_primary_length == 0 - ): - return np.nan - - # Calculate and return gravitropism index - grav_index = (max_primary_length - max_base_tip_distance) / max_primary_length - return grav_index + return grav_index diff --git a/sleap_roots/trait_pipelines.py b/sleap_roots/trait_pipelines.py index d6f1e65..6c18d55 100644 --- a/sleap_roots/trait_pipelines.py +++ b/sleap_roots/trait_pipelines.py @@ -899,3 +899,474 @@ def get_initial_frame_traits(self, plant: Series, frame_idx: int) -> Dict[str, A primary_pts = np.stack([inst.numpy() for inst in gt_instances_pr], axis=0) return {"primary_pts": primary_pts, "lateral_pts": lateral_pts} + + +@attrs.define +class YoungerMonocotPipeline(Pipeline): + """Pipeline for computing traits for young monocot plants (primary + seminal). + + Attributes: + img_height: Image height. + img_width: Image width. + n_scanlines: Number of scan lines, np.nan for no interaction. + network_fraction: Length found in the lower fraction value of the network. + """ + + img_height: int = 1080 + img_width: int = 2048 + n_scanlines: int = 50 + network_fraction: float = 2 / 3 + + def define_traits(self) -> List[TraitDef]: + """Define the trait computation pipeline for younger monocot plants.""" + trait_definitions = [ + TraitDef( + name="primary_max_length_pts", + fn=get_max_length_pts, + input_traits=["primary_pts"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Points of the primary root with maximum length.", + ), + TraitDef( + name="pts_all_array", + fn=get_all_pts_array, + input_traits=["primary_max_length_pts", "main_pts"], + scalar=False, + include_in_csv=False, + kwargs={"monocots": True}, + description="Landmark points within a given frame as a flat array" + "of coordinates.", + ), + TraitDef( + name="main_count", + fn=get_lateral_count, + input_traits=["main_pts"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Get the number of main roots.", + ), + TraitDef( + name="main_proximal_node_inds", + fn=get_node_ind, + input_traits=["main_pts"], + scalar=False, + include_in_csv=False, + kwargs={"proximal": True}, + description="Get the indices of the proximal nodes of main roots.", + ), + TraitDef( + name="main_distal_node_inds", + fn=get_node_ind, + input_traits=["main_pts"], + scalar=False, + include_in_csv=False, + kwargs={"proximal": False}, + description="Get the indices of the distal nodes of main roots.", + ), + TraitDef( + name="main_lengths", + fn=get_root_lengths, + input_traits=["main_pts"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Array of main root lengths of shape `(instances,)`.", + ), + TraitDef( + name="main_base_pts", + fn=get_bases, + input_traits=["main_pts"], + scalar=False, + include_in_csv=False, + kwargs={"monocots": False}, + description="Array of main bases `(instances, (x, y))`.", + ), + TraitDef( + name="main_tip_pts", + fn=get_tips, + input_traits=["main_pts"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Array of main tips `(instances, (x, y))`.", + ), + TraitDef( + name="scanline_intersection_counts", + fn=count_scanline_intersections, + input_traits=["primary_max_length_pts", "main_pts"], + scalar=False, + include_in_csv=True, + kwargs={ + "height": self.img_height, + "width": self.img_width, + "n_line": self.n_scanlines, + "monocots": True, + }, + description="Array of intersections of each scanline" + "`(n_scanlines,)`.", + ), + TraitDef( + name="main_angles_distal", + fn=get_root_angle, + input_traits=["main_pts", "main_distal_node_inds"], + scalar=False, + include_in_csv=True, + kwargs={"proximal": False, "base_ind": 0}, + description="Array of main distal angles in degrees `(instances,)`.", + ), + TraitDef( + name="main_angles_proximal", + fn=get_root_angle, + input_traits=["main_pts", "main_proximal_node_inds"], + scalar=False, + include_in_csv=True, + kwargs={"proximal": True, "base_ind": 0}, + description="Array of main proximal angles in degrees " + "`(instances,)`.", + ), + TraitDef( + name="network_length_lower", + fn=get_network_distribution, + input_traits=[ + "primary_max_length_pts", + "main_pts", + "bounding_box", + ], + scalar=True, + include_in_csv=True, + kwargs={"fraction": self.network_fraction, "monocots": True}, + description="Scalar of the root network length in the lower fraction " + "of the plant.", + ), + TraitDef( + name="ellipse", + fn=fit_ellipse, + input_traits=["pts_all_array"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Tuple of (a, b, ratio) containing the semi-major axis " + "length, semi-minor axis length, and the ratio of the major to minor " + "lengths.", + ), + TraitDef( + name="bounding_box", + fn=get_bbox, + input_traits=["pts_all_array"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Tuple of four parameters in bounding box.", + ), + TraitDef( + name="convex_hull", + fn=get_convhull, + input_traits=["pts_all_array"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Convex hull of the points.", + ), + TraitDef( + name="primary_proximal_node_ind", + fn=get_node_ind, + input_traits=["primary_max_length_pts"], + scalar=True, + include_in_csv=False, + kwargs={"proximal": True}, + description="Get the indices of the proximal nodes of primary roots.", + ), + TraitDef( + name="primary_angle_proximal", + fn=get_root_angle, + input_traits=[ + "primary_max_length_pts", + "primary_proximal_node_ind", + ], + scalar=True, + include_in_csv=True, + kwargs={"proximal": True, "base_ind": 0}, + description="Array of primary proximal angles in degrees " + "`(instances,)`.", + ), + TraitDef( + name="primary_distal_node_ind", + fn=get_node_ind, + input_traits=["primary_max_length_pts"], + scalar=True, + include_in_csv=False, + kwargs={"proximal": False}, + description="Get the indices of the distal nodes of primary roots.", + ), + TraitDef( + name="primary_angle_distal", + fn=get_root_angle, + input_traits=["primary_max_length_pts", "primary_distal_node_ind"], + scalar=True, + include_in_csv=True, + kwargs={"proximal": False, "base_ind": 0}, + description="Array of primary distal angles in degrees `(instances,)`.", + ), + TraitDef( + name="primary_length", + fn=get_root_lengths, + input_traits=["primary_max_length_pts"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of primary root length.", + ), + TraitDef( + name="primary_base_pt", + fn=get_bases, + input_traits=["primary_max_length_pts"], + scalar=False, + include_in_csv=False, + kwargs={"monocots": False}, + description="Primary root base point.", + ), + TraitDef( + name="primary_tip_pt", + fn=get_tips, + input_traits=["primary_max_length_pts"], + scalar=False, + include_in_csv=False, + kwargs={}, + description="Primary root tip point.", + ), + TraitDef( + name="main_tip_xs", + fn=get_tip_xs, + input_traits=["main_tip_pts"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Array of the x-coordinates of main tips `(instance,)`.", + ), + TraitDef( + name="main_tip_ys", + fn=get_tip_ys, + input_traits=["main_tip_pts"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Array of the y-coordinates of main tips `(instance,)`.", + ), + TraitDef( + name="network_distribution_ratio", + fn=get_network_distribution_ratio, + input_traits=[ + "primary_length", + "main_lengths", + "network_length_lower", + ], + scalar=True, + include_in_csv=True, + kwargs={"fraction": self.network_fraction, "monocots": False}, + description="Scalar of ratio of the root network length in the lower" + "fraction of the plant over all root length.", + ), + TraitDef( + name="network_length", + fn=get_network_length, + input_traits=["primary_length", "main_lengths"], + scalar=True, + include_in_csv=True, + kwargs={"monocots": True}, + description="Scalar of all roots network length.", + ), + TraitDef( + name="main_base_tip_dists", + fn=get_base_tip_dist, + input_traits=["main_base_pts", "main_tip_pts"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Straight-line distance(s) from the base(s) to the" + "tip(s) of the main root(s).", + ), + TraitDef( + name="main_grav_indices", + fn=get_base_tip_dist, + input_traits=["main_base_pts", "main_tip_pts"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Gravitropism index for each main root.", + ), + TraitDef( + name="network_solidity", + fn=get_network_solidity, + input_traits=["network_length", "chull_area"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of the total network length divided by the" + "network convex hull area.", + ), + TraitDef( + name="primary_tip_pt_y", + fn=get_tip_ys, + input_traits=["primary_tip_pt"], + scalar=True, + include_in_csv=True, + kwargs={"flatten": True}, + description="Y-coordinate of the primary root tip node.", + ), + TraitDef( + name="ellipse_a", + fn=get_ellipse_a, + input_traits=["ellipse"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of semi-major axis length.", + ), + TraitDef( + name="ellipse_b", + fn=get_ellipse_b, + input_traits=["ellipse"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of semi-minor axis length.", + ), + TraitDef( + name="network_width_depth_ratio", + fn=get_network_width_depth_ratio, + input_traits=["bounding_box"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of bounding box width to depth ratio of root " + "network.", + ), + TraitDef( + name="chull_perimeter", + fn=get_chull_perimeter, + input_traits=["convex_hull"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of convex hull perimeter.", + ), + TraitDef( + name="chull_area", + fn=get_chull_area, + input_traits=["convex_hull"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of convex hull area.", + ), + TraitDef( + name="chull_max_width", + fn=get_chull_max_width, + input_traits=["convex_hull"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of convex hull maximum width.", + ), + TraitDef( + name="chull_max_height", + fn=get_chull_max_height, + input_traits=["convex_hull"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of convex hull maximum height.", + ), + TraitDef( + name="chull_line_lengths", + fn=get_chull_line_lengths, + input_traits=["convex_hull"], + scalar=False, + include_in_csv=True, + kwargs={}, + description="Array of line lengths connecting any two vertices on the" + "convex hull.", + ), + TraitDef( + name="grav_index", + fn=get_grav_index, + input_traits=["primary_length", "primary_base_tip_dist"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of primary root gravity index.", + ), + TraitDef( + name="primary_base_tip_dist", + fn=get_base_tip_dist, + input_traits=["primary_base_pt", "primary_tip_pt"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of distance from primary root base to tip.", + ), + TraitDef( + name="ellipse_ratio", + fn=get_ellipse_ratio, + input_traits=["ellipse"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of ratio of the minor to major lengths.", + ), + TraitDef( + name="scanline_last_ind", + fn=get_scanline_last_ind, + input_traits=["scanline_intersection_counts"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of count_scanline_interaction index for the last" + "interaction.", + ), + TraitDef( + name="scanline_first_ind", + fn=get_scanline_first_ind, + input_traits=["scanline_intersection_counts"], + scalar=True, + include_in_csv=True, + kwargs={}, + description="Scalar of count_scanline_interaction index for the first" + "interaction.", + ), + ] + + return trait_definitions + + def get_initial_frame_traits(self, plant: Series, frame_idx: int) -> Dict[str, Any]: + """Return initial traits for a plant frame. + + Args: + plant: The plant `Series` object. + frame_idx: The index of the current frame. + + Returns: + A dictionary of initial traits with keys: + - "primary_pts": Array of primary root points. + - "main_pts": Array of main root points. + """ + # Get the root instances. + primary, main = plant[frame_idx] + gt_instances_pr = primary.user_instances + primary.unused_predictions + gt_instances_lr = main.user_instances + main.unused_predictions + + # Convert the instances to numpy arrays. + if len(gt_instances_lr) == 0: + main_pts = np.array([[(np.nan, np.nan), (np.nan, np.nan)]]) + else: + main_pts = np.stack([inst.numpy() for inst in gt_instances_lr], axis=0) + + if len(gt_instances_pr) == 0: + primary_pts = np.array([[(np.nan, np.nan), (np.nan, np.nan)]]) + else: + primary_pts = np.stack([inst.numpy() for inst in gt_instances_pr], axis=0) + + return {"primary_pts": primary_pts, "main_pts": main_pts} diff --git a/tests/data/rice_3do/0K9E8BI.h5 b/tests/data/rice_3do/0K9E8BI.h5 new file mode 100644 index 0000000..da83e3b Binary files /dev/null and b/tests/data/rice_3do/0K9E8BI.h5 differ diff --git a/tests/data/rice_3do/0K9E8BI.longest_3do_6nodes.predictions.slp b/tests/data/rice_3do/0K9E8BI.longest_3do_6nodes.predictions.slp new file mode 100644 index 0000000..bac1b56 Binary files /dev/null and b/tests/data/rice_3do/0K9E8BI.longest_3do_6nodes.predictions.slp differ diff --git a/tests/data/rice_3do/0K9E8BI.main_3do_6nodes.predictions.slp b/tests/data/rice_3do/0K9E8BI.main_3do_6nodes.predictions.slp new file mode 100644 index 0000000..9f19a8a Binary files /dev/null and b/tests/data/rice_3do/0K9E8BI.main_3do_6nodes.predictions.slp differ diff --git a/tests/test_lengths.py b/tests/test_lengths.py index ba05fc2..1c1a3f5 100644 --- a/tests/test_lengths.py +++ b/tests/test_lengths.py @@ -146,8 +146,8 @@ def lengths_all_nan(): return np.array([np.nan, np.nan, np.nan]) -# test get_grav_index function -def test_get_grav_index(canola_h5): +# tests for get_grav_index function +def test_get_grav_index_canola(canola_h5): series = Series.load( canola_h5, primary_name="primary_multi_day", lateral_name="lateral_3_nodes" ) @@ -162,6 +162,98 @@ def test_get_grav_index(canola_h5): np.testing.assert_almost_equal(grav_index, 0.08898137324716636) +def test_get_grav_index(): + # Test 1: Scalar inputs where length > base_tip_dist + # Gravitropism index should be (10 - 8) / 10 = 0.2 + assert get_grav_index(10, 8) == 0.2 + + # Test 2: Scalar inputs where length and base_tip_dist are zero + # Should return NaN as length is zero + assert np.isnan(get_grav_index(0, 0)) + + # Test 3: Scalar inputs where length < base_tip_dist + # Should return NaN as it's an invalid case + assert np.isnan(get_grav_index(5, 10)) + + # Test 4: Array inputs covering various cases + # Case 1: length > base_tip_dist, should return 0.2 + # Case 2: length = 0, should return NaN + # Case 3: length < base_tip_dist, should return NaN + # Case 4: length > base_tip_dist, should return 0.2 + lengths = np.array([10, 0, 5, 15]) + base_tip_dists = np.array([8, 0, 10, 12]) + expected = np.array([0.2, np.nan, np.nan, 0.2]) + result = get_grav_index(lengths, base_tip_dists) + assert np.allclose(result, expected, equal_nan=True) + + # Test 5: Mismatched shapes between lengths and base_tip_dists + # Should raise a ValueError + with pytest.raises(ValueError): + get_grav_index(np.array([10, 20]), np.array([8])) + + # Test 6: Array inputs with NaN values + # Case 1: length > base_tip_dist, should return 0.2 + # Case 2 and 3: either length or base_tip_dist is NaN, should return NaN + lengths = np.array([10, np.nan, np.nan]) + base_tip_dists = np.array([8, 8, np.nan]) + expected = np.array([0.2, np.nan, np.nan]) + result = get_grav_index(lengths, base_tip_dists) + assert np.allclose(result, expected, equal_nan=True) + + +def test_get_grav_index_shape(): + # Check if scalar inputs result in scalar output + result = get_grav_index(10, 8) + assert isinstance( + result, (int, float) + ), f"Expected scalar output, got {type(result)}" + + # Check if array inputs result in array output + lengths = np.array([10, 15]) + base_tip_dists = np.array([8, 12]) + result = get_grav_index(lengths, base_tip_dists) + assert isinstance( + result, np.ndarray + ), f"Expected np.ndarray output, got {type(result)}" + + # Check the shape of output for array inputs + # Should match the shape of the input arrays + assert ( + result.shape == lengths.shape + ), f"Output shape {result.shape} does not match input shape {lengths.shape}" + + # Check the shape of output for larger array inputs + lengths = np.array([10, 15, 20, 25]) + base_tip_dists = np.array([8, 12, 18, 22]) + result = get_grav_index(lengths, base_tip_dists) + assert ( + result.shape == lengths.shape + ), f"Output shape {result.shape} does not match input shape {lengths.shape}" + + +def test_nan_values(): + lengths = np.array([10, np.nan, 30]) + base_tip_dists = np.array([8, 16, np.nan]) + np.testing.assert_array_equal( + get_grav_index(lengths, base_tip_dists), np.array([0.2, np.nan, np.nan]) + ) + + +def test_zero_lengths(): + lengths = np.array([0, 20, 30]) + base_tip_dists = np.array([0, 16, 24]) + np.testing.assert_array_equal( + get_grav_index(lengths, base_tip_dists), np.array([np.nan, 0.2, 0.2]) + ) + + +def test_invalid_scalar_values(): + assert np.isnan(get_grav_index(np.nan, 8)) + assert np.isnan(get_grav_index(10, np.nan)) + assert np.isnan(get_grav_index(0, 8)) + + +# tests for `get_root_lengths` def test_get_root_lengths(canola_h5): series = Series.load( canola_h5, primary_name="primary_multi_day", lateral_name="lateral_3_nodes" diff --git a/tests/test_trait_pipelines.py b/tests/test_trait_pipelines.py index bb2659e..a1deac2 100644 --- a/tests/test_trait_pipelines.py +++ b/tests/test_trait_pipelines.py @@ -1,5 +1,5 @@ -from sleap_roots.trait_pipelines import DicotPipeline -from sleap_roots.series import Series +from sleap_roots.trait_pipelines import DicotPipeline, YoungerMonocotPipeline +from sleap_roots.series import Series, find_all_series def test_dicot_pipeline(canola_h5, soy_h5): @@ -18,3 +18,64 @@ def test_dicot_pipeline(canola_h5, soy_h5): assert canola_traits.shape == (72, 117) assert soy_traits.shape == (72, 117) assert all_traits.shape == (2, 1036) + + +def test_younger_monocot_pipeline(rice_h5, rice_folder): + rice = Series.load( + rice_h5, primary_name="longest_3do_6nodes", lateral_name="main_3do_6nodes" + ) + rice_series_all = find_all_series(rice_folder) + series_all = [ + Series.load( + series, primary_name="longest_3do_6nodes", lateral_name="main_3do_6nodes" + ) + for series in rice_series_all + ] + + pipeline = YoungerMonocotPipeline() + rice_traits = pipeline.compute_plant_traits(rice) + all_traits = pipeline.compute_batch_traits(series_all) + + # Dataframe shape assertions + assert rice_traits.shape == (72, 104) + assert all_traits.shape == (2, 919) + + # Dataframe dtype assertions + expected_rice_traits_dtypes = { + "frame_idx": "int64", + "main_count": "int64", + } + + expected_all_traits_dtypes = { + "main_count_min": "int64", + "main_count_max": "int64", + } + + for col, expected_dtype in expected_rice_traits_dtypes.items(): + assert ( + rice_traits[col].dtype == expected_dtype + ), f"Unexpected dtype for column {col} in rice_traits" + + for col, expected_dtype in expected_all_traits_dtypes.items(): + assert ( + all_traits[col].dtype == expected_dtype + ), f"Unexpected dtype for column {col} in all_traits" + + # Value range assertions for traits + assert ( + rice_traits["grav_index"].fillna(0) >= 0 + ).all(), "grav_index in rice_traits contains negative values" + assert ( + all_traits["grav_index_median"] >= 0 + ).all(), "grav_index in all_traits contains negative values" + assert ( + all_traits["main_grav_indices_mean_median"] >= 0 + ).all(), "main_grav_indices_mean_median in all_traits contains negative values" + assert ( + (0 <= rice_traits["main_angles_proximal_p95"]) + & (rice_traits["main_angles_proximal_p95"] <= 180) + ).all(), "angle_column in rice_traits contains values out of range [0, 180]" + assert ( + (0 <= all_traits["main_angles_proximal_median_p95"]) + & (all_traits["main_angles_proximal_median_p95"] <= 180) + ).all(), "angle_column in all_traits contains values out of range [0, 180]"