From c481c42e5b2f9ad543115dbca5e0ad12c664fccd Mon Sep 17 00:00:00 2001 From: Joao P Dubas Date: Wed, 30 Apr 2025 22:40:45 +0000 Subject: [PATCH] feat(growth): add weight-for-height calculation --- lib/growth/score/weight_for_height.ex | 90 ++++++++++++ test/growth/score/weight_for_height_test.exs | 142 +++++++++++++++++++ test/support/growth_data.ex | 8 ++ 3 files changed, 240 insertions(+) create mode 100644 test/growth/score/weight_for_height_test.exs diff --git a/lib/growth/score/weight_for_height.ex b/lib/growth/score/weight_for_height.ex index 27d7afb..4bbceb4 100644 --- a/lib/growth/score/weight_for_height.ex +++ b/lib/growth/score/weight_for_height.ex @@ -1,5 +1,95 @@ defmodule Growth.Score.WeightForHeight do @moduledoc """ Calculate z-score for weight for height. + + This module calculates the z-score for weight relative to height, which is an important + indicator for assessing whether a child's weight is appropriate for their height, + regardless of age. + + **Limitation**: the memasurements do not differentiate between length and height, and + always assume height. """ + + @behaviour Growth.Score.Scorer + + alias Growth.Score.Scorer + + @impl Scorer + @spec measure_name() :: atom() + @doc """ + Name of the measurement used in weight for height indicator. + """ + def measure_name do + :weight_for_height + end + + @doc """ + Custom implementation for weight-for-height lookup, as it requires both weight and height. + + This overrides the default implementation in the Scorer module. + """ + @spec lms(Growth.t(), module()) :: [{String.t(), {number(), number(), number()}}] + def lms(%Growth{gender: gender, weight: weight, height: height}, _indicator) + when is_number(weight) and is_number(height) do + # For weight-for-height, we use height as the lookup value instead of age + key = {{gender, :height, height}, :_} + + case :ets.match_object(__MODULE__, key) do + [{{^gender, _, ^height}, %{l: l, m: m, s: s}} | _] -> + [{"height", {l, m, s}}] + + _ -> + # Try to find the closest height value + find_closest_height(gender, height) + end + end + + def lms(_growth, _indicator) do + [] + end + + @doc """ + Find the closest height value in the ETS table when an exact match isn't found. + """ + @spec find_closest_height(Growth.gender(), number()) :: [ + {String.t(), {number(), number(), number()}} + ] + def find_closest_height(gender, height) do + # Fetch all entries for the given gender and height measurement + matcher = [ + { + {{:"$1", :"$2", :"$3"}, :_}, + [ + {:andalso, + {:andalso, {:andalso, {:==, :"$1", gender}, {:==, :"$2", :height}}, + {:>, :"$3", height - 1.0}}, {:<, :"$3", height + 1.0}} + ], + [:"$_"] + } + ] + + gender_height_entries = :ets.select(__MODULE__, matcher) + + case gender_height_entries do + [] -> + # No entries found for this gender + [] + + entries -> + # Find the entry with the height closest to the target height + closest_entry = + entries + |> Enum.filter(fn {{_, _, entry_height}, _lms_data} -> + abs(entry_height - height) <= 0.5 + end) + |> Enum.min_by(fn {{_, _, entry_height}, _lms_data} -> + abs(entry_height - height) + end) + + # Extract the LMS data from the closest entry + {_, %{l: l, m: m, s: s}} = closest_entry + + [{"height", {l, m, s}}] + end + end end diff --git a/test/growth/score/weight_for_height_test.exs b/test/growth/score/weight_for_height_test.exs new file mode 100644 index 0000000..ee36926 --- /dev/null +++ b/test/growth/score/weight_for_height_test.exs @@ -0,0 +1,142 @@ +# test/growth/score/weight_for_height_test.exs +defmodule Growth.Score.WeightForHeightTest do + use ExUnit.Case, async: true + + alias Growth + alias Growth.Score.WeightForHeight + + describe "measure_name/0" do + test "returns the correct measure name atom" do + assert WeightForHeight.measure_name() == :weight_for_height + end + end + + describe "lms/2" do + test "returns LMS values for exact height match" do + growth = %Growth{ + gender: :male, + weight: 7.3, + height: 65.0, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth, WeightForHeight) == [ + {"height", {-0.35210000000000002, 7.4326999999999996, 0.082170000000000007}} + ] + end + + test "returns LMS values for closest height match when exact is not found" do + growth = %Growth{ + gender: :male, + weight: 7.3, + height: 64.9, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth, WeightForHeight) == [ + {"height", {-0.35210000000000002, 7.4326999999999996, 0.082170000000000007}} + ] + + growth_closer_to_higher = %Growth{ + gender: :male, + weight: 7.4, + height: 65.09, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth_closer_to_higher, WeightForHeight) == [ + {"height", {-0.35210000000000002, 7.4562999999999997, 0.082159999999999997}} + ] + end + + test "returns empty list if weight or height is missing" do + growth_no_weight = %Growth{ + gender: :male, + height: 60.0, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth_no_weight, WeightForHeight) == [] + + growth_no_height = %Growth{ + gender: :male, + weight: 5.0, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth_no_height, WeightForHeight) == [] + + growth_nil_values = %Growth{ + gender: :male, + weight: nil, + height: nil, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth_nil_values, WeightForHeight) == [] + end + + test "returns empty list if no matching gender data exists" do + # Assuming no :unknown gender data was inserted + growth = %Growth{ + gender: :unknown, + weight: 5.0, + height: 60.0, + name: "Joe Doe", + date_of_measurement: Date.utc_today(), + date_of_birth: Date.shift(Date.utc_today(), day: -91) + } + + assert WeightForHeight.lms(growth, WeightForHeight) == [] + end + end + + describe "find_closest_height/2" do + test "finds the lower closest height" do + assert WeightForHeight.find_closest_height(:male, 64.99) == [ + {"height", {-0.35210000000000002, 7.4326999999999996, 0.082170000000000007}} + ] + end + + test "finds the higher closest height" do + assert WeightForHeight.find_closest_height(:male, 65.61) == [ + {"height", {-0.35210000000000002, 7.5738000000000003, 0.082140000000000005}} + ] + end + + test "finds the lower closest height when exactly midway" do + expected_lms = + [ + [{"height", {-0.35210000000000002, 7.5034000000000001, 0.082150000000000001}}], + [{"height", {-0.35210000000000002, 7.4798999999999998, 0.082159999999999997}}] + ] + + lms = WeightForHeight.find_closest_height(:male, 65.25) + assert Enum.any?(expected_lms, fn entity_lms -> entity_lms == lms end) + end + + test "do not match when below minimum height" do + assert WeightForHeight.find_closest_height(:male, 44.0) == [] + end + + test "do not match when above maximum height" do + assert WeightForHeight.find_closest_height(:male, 121.0) == [] + end + + test "returns empty list when no data exists for the gender" do + assert WeightForHeight.find_closest_height(:unknown, 60.0) == [] + end + end +end diff --git a/test/support/growth_data.ex b/test/support/growth_data.ex index 0d2d101..b5df230 100644 --- a/test/support/growth_data.ex +++ b/test/support/growth_data.ex @@ -61,6 +61,14 @@ defmodule Growth.Data do weight-for-age,male,day,28,0.2331,4.3670999999999998,0.13497000000000001,2.8540000000000001,3.3050000000000002,3.8069999999999999,4.367,4.9880000000000004,5.6740000000000004,6.43 weight-for-age,male,week,4,0.2331,4.3670999999999998,0.13497000000000001,2.9,3.3,3.8,4.4000000000000004,5,5.7,6.4 weight-for-age,male,month,1,0.22969999999999999,4.4709000000000003,0.13395000000000001,2.9,3.4,3.9,4.5,5.0999999999999996,5.8,6.6 + weight-for-height,female,length,65,-0.38329999999999997,7.0811999999999999,0.091189999999999993,5.4589999999999996,5.9370000000000003,6.4740000000000002,7.0810000000000004,7.77,8.5549999999999997,9.4540000000000006 + weight-for-height,female,length,65.099999999999994,-0.38329999999999997,7.1040999999999999,0.091179999999999997,5.4770000000000003,5.9560000000000004,6.4950000000000001,7.1040000000000001,7.7949999999999999,8.5820000000000007,9.484 + weight-for-height,female,height,65,-0.38329999999999997,7.2401999999999997,0.091130000000000003,5.5830000000000002,6.0709999999999997,6.62,7.24,7.944,8.7460000000000004,9.6639999999999997 + weight-for-height,female,height,65.099999999999994,-0.38329999999999997,7.2626999999999997,0.091120000000000007,5.6,6.09,6.64,7.2629999999999999,7.9690000000000003,8.7729999999999997,9.6940000000000008 + weight-for-height,male,length,65,-0.35210000000000002,7.2666000000000004,0.082229999999999998,5.7359999999999998,6.1929999999999996,6.7009999999999996,7.2670000000000003,7.899,8.6080000000000005,9.4060000000000006 + weight-for-height,male,length,65.099999999999994,-0.35210000000000002,7.2904999999999998,0.082220000000000001,5.7549999999999999,6.2130000000000001,6.7229999999999999,7.29,7.9249999999999998,8.6359999999999992,9.4369999999999994 + weight-for-height,male,height,65,-0.35210000000000002,7.4326999999999996,0.082170000000000007,5.8680000000000003,6.335,6.8540000000000001,7.4329999999999998,8.0790000000000006,8.8040000000000003,9.6189999999999998 + weight-for-height,male,height,65.099999999999994,-0.35210000000000002,7.4562999999999997,0.082159999999999997,5.8869999999999996,6.3550000000000004,6.8760000000000003,7.4560000000000004,8.1050000000000004,8.8309999999999995,9.6489999999999991 """ @doc """