From 621217544903a1bbfc061da78e2f31e345890198 Mon Sep 17 00:00:00 2001 From: Harald Mack Date: Tue, 3 Dec 2024 14:23:32 +0100 Subject: [PATCH] add database models for statistics --- .../src/mondey_backend/models/milestones.py | 32 ++- .../routers/admin_routers/milestones.py | 4 +- .../src/mondey_backend/routers/scores.py | 238 ++++++------------ .../src/mondey_backend/routers/utils.py | 67 ++--- mondey_backend/tests/utils/test_scores.py | 59 ----- mondey_backend/tests/utils/test_utils.py | 16 +- 6 files changed, 144 insertions(+), 272 deletions(-) diff --git a/mondey_backend/src/mondey_backend/models/milestones.py b/mondey_backend/src/mondey_backend/models/milestones.py index 14f9f4bf..44ded7bb 100644 --- a/mondey_backend/src/mondey_backend/models/milestones.py +++ b/mondey_backend/src/mondey_backend/models/milestones.py @@ -193,17 +193,23 @@ class MilestoneAnswerSessionPublic(SQLModel): answers: dict[int, MilestoneAnswerPublic] -class Statistics(SQLModel): +class MilestoneAgeScore(SQLModel, table=True): + id: int | None = Field(primary_key=True, default=None) + collection_id: int | None = Field( + default=None, foreign_key="milestoneagescorecollection.id" + ) + collection: MilestoneAgeScoreCollection = back_populates("scores") avg_score: float stddev_score: float age_months: int expected_score: float -class MilestoneAgeScores(SQLModel, table=True): - milestone_id: int = Field(primary_key=True, default=None) - scores: list[Statistics] +class MilestoneAgeScoreCollection(SQLModel, table=True): + id: int | None = Field(primary_key=True, default=None) + milestone_id: int = Field(default=None, foreign_key="milestone.id") expected_age: int + scores: Mapped[list[MilestoneAgeScore]] = back_populates("collection") created_at: datetime.datetime = Field( sa_column_kwargs={ "server_default": text("CURRENT_TIMESTAMP"), @@ -211,9 +217,21 @@ class MilestoneAgeScores(SQLModel, table=True): ) -class MilestoneGroupAgeScores(SQLModel, table=True): - milestonegroup_id: int = Field(primary_key=True, default=None) - scores: list[Statistics] +class MilestoneGroupAgeScore(SQLModel, table=True): + id: int = Field(primary_key=True, default=None) + collection_id: int | None = Field( + default=None, foreign_key="milestonegroupagescorecollection.id" + ) + collection: MilestoneGroupAgeScoreCollection = back_populates("scores") + avg_score: float + stddev_score: float + age_months: int + + +class MilestoneGroupAgeScoreCollection(SQLModel, table=True): + id: int | None = Field(primary_key=True, default=None) + milestonegroup_id: int = Field(default=None, foreign_key="milestonegroup.id") + scores: Mapped[list[MilestoneGroupAgeScore]] = back_populates("collection") created_at: datetime.datetime = Field( sa_column_kwargs={ "server_default": text("CURRENT_TIMESTAMP"), diff --git a/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py b/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py index 29402cb1..06b4e2cb 100644 --- a/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py +++ b/mondey_backend/src/mondey_backend/routers/admin_routers/milestones.py @@ -9,7 +9,7 @@ from ...models.milestones import Language from ...models.milestones import Milestone from ...models.milestones import MilestoneAdmin -from ...models.milestones import MilestoneAgeScores +from ...models.milestones import MilestoneAgeScoreCollection from ...models.milestones import MilestoneGroup from ...models.milestones import MilestoneGroupAdmin from ...models.milestones import MilestoneGroupText @@ -183,7 +183,7 @@ async def delete_submitted_milestone_image( @router.get("/milestone-age-scores/{milestone_id}") def get_milestone_age_scores( session: SessionDep, milestone_id: int - ) -> MilestoneAgeScores: + ) -> MilestoneAgeScoreCollection: return calculate_milestone_statistics_by_age(session, milestone_id) return router diff --git a/mondey_backend/src/mondey_backend/routers/scores.py b/mondey_backend/src/mondey_backend/routers/scores.py index c1dd7399..2a9f7709 100644 --- a/mondey_backend/src/mondey_backend/routers/scores.py +++ b/mondey_backend/src/mondey_backend/routers/scores.py @@ -3,20 +3,13 @@ from enum import Enum import numpy as np -from sqlmodel import col -from sqlmodel import select -from ..dependencies import CurrentActiveUserDep from ..dependencies import SessionDep from ..models.children import Child from ..models.milestones import MilestoneAgeScore -from ..models.milestones import MilestoneAgeScores +from ..models.milestones import MilestoneAgeScoreCollection from ..models.milestones import MilestoneAnswer from ..models.milestones import MilestoneAnswerSession -from ..models.milestones import MilestoneGroupStatistics -from .utils import _session_has_expired -from .utils import calculate_milestone_statistics_by_age -from .utils import calculate_milestonegroup_statistics from .utils import get_child_age_in_months from .utils import get_milestonegroups_for_answersession @@ -38,7 +31,7 @@ class TrafficLight(Enum): def compute_feedback_simple( - stat: MilestoneAgeScore | MilestoneGroupStatistics, + stat: MilestoneAgeScore, score: float, min_score: float | None = None, ) -> int: @@ -59,54 +52,57 @@ def compute_feedback_simple( 1 if score > avg - stddev (trafficlight: green) """ + # TODO: implement logic anew with the new answersession structure def leq(val: float, lim: float) -> bool: return val < lim or np.isclose(val, lim) - if stat.stddev_score < 1e-2: - # README: This happens when all the scores are the same, so any - # deviation towards lower values can be interpreted as - # underperformance. - # This logic relies on the score being integers, such that when the - # stddev is 0, the avg is an integer - # TODO: Check again what client wants to happen in such cases? - lim_lower = stat.avg_score - 2 - lim_upper = stat.avg_score - 1 - else: - lim_lower = stat.avg_score - 2 * stat.stddev_score - lim_upper = stat.avg_score - stat.stddev_score - - if leq(score, lim_lower): - return TrafficLight.red.value - elif score > lim_lower and leq(score, lim_upper): - if min_score is not None and min_score < lim_lower: - return TrafficLight.yellowWithCaveat.value - return TrafficLight.yellow.value - else: - if min_score is not None and min_score < lim_upper: - return TrafficLight.greenWithCaveat.value - return TrafficLight.green.value + return TrafficLight.green.value + # if stat.stddev_score < 1e-2: + # # README: This happens when all the scores are the same, so any + # # deviation towards lower values can be interpreted as + # # underperformance. + # # This logic relies on the score being integers, such that when the + # # stddev is 0, the avg is an integer + # # TODO: Check again what client wants to happen in such cases? + # lim_lower = stat.avg_score - 2 + # lim_upper = stat.avg_score - 1 + # else: + # lim_lower = stat.avg_score - 2 * stat.stddev_score + # lim_upper = stat.avg_score - stat.stddev_score + + # if leq(score, lim_lower): + # return TrafficLight.red.value + # elif score > lim_lower and leq(score, lim_upper): + # if min_score is not None and min_score < lim_lower: + # return TrafficLight.yellowWithCaveat.value + # return TrafficLight.yellow.value + # else: + # if min_score is not None and min_score < lim_upper: + # return TrafficLight.greenWithCaveat.value + # return TrafficLight.green.value def compute_detailed_feedback_for_answers( session: SessionDep, answers: list[MilestoneAnswer], - statistics: dict[int, MilestoneAgeScores], + statistics: dict[int, MilestoneAgeScoreCollection], age: int, ) -> dict[int, int]: milestonegroup_result: dict[int, int] = {} # type: ignore - for answer in answers: - if statistics.get(answer.milestone_id) is None: # type: ignore - stat = calculate_milestone_statistics_by_age( - session, - answer.milestone_id, # type: ignore - ) # type: ignore - - statistics[answer.milestone_id] = stat # type: ignore - feedback = compute_feedback_simple( - statistics[answer.milestone_id].scores[age], # type: ignore - answer.answer, # type: ignore - ) # type: ignore - milestonegroup_result[answer.milestone_id] = feedback # type: ignore + # TODO: implement logic anew with the new answersession structure + # for answer in answers: + # if statistics.get(answer.milestone_id) is None: # type: ignore + # stat = calculate_milestone_statistics_by_age( + # session, + # answer.milestone_id, # type: ignore + # ) # type: ignore + + # statistics[answer.milestone_id] = stat # type: ignore + # feedback = compute_feedback_simple( + # statistics[answer.milestone_id].scores[age], # type: ignore + # answer.answer, # type: ignore + # ) # type: ignore + # milestonegroup_result[answer.milestone_id] = feedback # type: ignore return milestonegroup_result @@ -115,25 +111,27 @@ def compute_detailed_milestonegroup_feedback_for_answersession( answersession: MilestoneAnswerSession, child: Child, ) -> dict[int, dict[int, int]]: + # TODO: implement logic anew with the new answersession structure + age = get_child_age_in_months(child, answersession.created_at) milestonegroups = get_milestonegroups_for_answersession(session, answersession) - filtered_answers = { - m.id: [ - answersession.answers[ms.id] - for ms in m.milestones - if ms.id in answersession.answers and ms.id is not None - ] - for mid, m in milestonegroups.items() - } + # filtered_answers = { + # m.id: [ + # answersession.answers[ms.id] + # for ms in m.milestones + # if ms.id in answersession.answers and ms.id is not None + # ] + # for mid, m in milestonegroups.items() + # } result: dict[int, dict[int, int]] = {} - statistics: dict[int, MilestoneAgeScores] = {} - for milestonegroup_id, answers in filtered_answers.items(): - milestonegroup_result = compute_detailed_feedback_for_answers( - session, answers, statistics, age - ) - result[milestonegroup_id] = milestonegroup_result # type: ignore + # statistics: dict[int, MilestoneAgeScoreCollection] = {} + # for milestonegroup_id, answers in filtered_answers.items(): + # milestonegroup_result = compute_detailed_feedback_for_answers( + # session, answers, statistics, age + # ) + # result[milestonegroup_id] = milestonegroup_result # type: ignore return result @@ -146,111 +144,25 @@ def compute_summary_milestonegroup_feedback_for_answersession( ) -> dict[int, int]: age = get_child_age_in_months(child, answersession.created_at) - # TODO: double check if this does the right thing + # TODO: implement logic anew with the new answersession structure milestonegroups = get_milestonegroups_for_answersession(session, answersession) - filtered_answers = { - milestonegroup.id: [ - answersession.answers[ms.id] - for ms in milestonegroup.milestones - if ms.id in answersession.answers and ms.id is not None - ] - for mid, milestonegroup in milestonegroups.items() - } - milestone_group_results: dict[int, int] = {} - for milestonegroup_id, answers in filtered_answers.items(): - mg_stat = calculate_milestonegroup_statistics( - session, - milestonegroup_id, # type: ignore - age, - age_lower=age - age_limit_low, - age_upper=age + age_limit_high, - ) - mg_stat.session_id = answersession.id # type: ignore - mg_stat.child_id = child.id # type: ignore - - mean_for_mg = np.nan_to_num(np.mean([a.answer for a in answers])) - min_for_mg = np.nan_to_num(np.min([a.answer for a in answers])) - - result = compute_feedback_simple(mg_stat, mean_for_mg, min_for_mg) - milestone_group_results[milestonegroup_id] = result # type: ignore + # for milestonegroup_id, answers in filtered_answers.items(): + # mg_stat = calculate_milestonegroup_statistics( + # session, + # milestonegroup_id, # type: ignore + # age, + # age_lower=age - age_limit_low, + # age_upper=age + age_limit_high, + # ) + # mg_stat.session_id = answersession.id # type: ignore + # mg_stat.child_id = child.id # type: ignore + + # mean_for_mg = np.nan_to_num(np.mean([a.answer for a in answers])) + # min_for_mg = np.nan_to_num(np.min([a.answer for a in answers])) + + # result = compute_feedback_simple(mg_stat, mean_for_mg, min_for_mg) + # milestone_group_results[milestonegroup_id] = result # type: ignore return milestone_group_results - - -def compute_summary_milestonegroup_feedback_for_all_sessions( - session: SessionDep, - child: Child, - age_limit_low=6, - age_limit_high=6, -) -> dict[str, dict[int, int]]: - results: dict[str, dict[int, int]] = {} - - # get all answer sessions and filter for completed ones - answersessions = [ - a - for a in session.exec( - select(MilestoneAnswerSession).where( - col(MilestoneAnswerSession.child_id) == child.id - ) - ).all() - if _session_has_expired(a) - ] - - if answersessions == []: - return results - else: - for answersession in answersessions: - milestone_group_results = ( - compute_summary_milestonegroup_feedback_for_answersession( - session, - answersession, - child, - age_limit_low=age_limit_low, - age_limit_high=age_limit_high, - ) - ) - - datestring = answersession.created_at.strftime("%d-%m-%Y") - results[datestring] = milestone_group_results - - return results - - -def compute_detailed_milestonegroup_feedback_for_all_sessions( - session: SessionDep, - current_active_user: CurrentActiveUserDep, - child: Child, -) -> dict[str, dict[int, dict[int, int]]]: - results: dict[str, dict[int, dict[int, int]]] = {} - - user = current_active_user() - # get all answer sessions and filter for completed ones - answersessions = [ - a - for a in session.exec( - select(MilestoneAnswerSession).where( - col(MilestoneAnswerSession.child_id) == child.id - and col(MilestoneAnswerSession.user_id) == user.id - ) - ).all() - if _session_has_expired(a) - ] - - if answersessions == []: - return results - else: - for answersession in answersessions: - milestone_group_results = ( - compute_detailed_milestonegroup_feedback_for_answersession( - session, - answersession, - child, - ) - ) - - datestring = answersession.created_at.strftime("%d-%m-%Y") - results[datestring] = milestone_group_results - - return results diff --git a/mondey_backend/src/mondey_backend/routers/utils.py b/mondey_backend/src/mondey_backend/routers/utils.py index 2f831ec6..9897eedd 100644 --- a/mondey_backend/src/mondey_backend/routers/utils.py +++ b/mondey_backend/src/mondey_backend/routers/utils.py @@ -23,15 +23,16 @@ from ..models.milestones import AgeInterval from ..models.milestones import Milestone from ..models.milestones import MilestoneAdmin -from ..models.milestones import MilestoneAgeScores +from ..models.milestones import MilestoneAgeScore +from ..models.milestones import MilestoneAgeScoreCollection from ..models.milestones import MilestoneAnswer from ..models.milestones import MilestoneAnswerSession from ..models.milestones import MilestoneGroup from ..models.milestones import MilestoneGroupAdmin -from ..models.milestones import MilestoneGroupAgeScores + +# from ..models.milestones import MilestoneGroupAgeScores from ..models.milestones import MilestoneGroupText from ..models.milestones import MilestoneText -from ..models.milestones import Statistics from ..models.questions import ChildQuestion from ..models.questions import ChildQuestionAdmin from ..models.questions import ChildQuestionText @@ -254,7 +255,7 @@ def calculate_milestone_statistics_by_age( session: SessionDep, milestone_id: int, answers: Sequence[MilestoneAnswer] | None = None, -) -> MilestoneAgeScores: +) -> MilestoneAgeScoreCollection: child_ages = _get_answer_session_child_ages_in_months(session) if answers is None: @@ -266,12 +267,12 @@ def calculate_milestone_statistics_by_age( avg, stddev = _get_statistics_by_age(answers, child_ages) expected_age = _get_expected_age_from_scores(avg) - return MilestoneAgeScores( + return MilestoneAgeScoreCollection( milestone_id=milestone_id, expected_age=expected_age, created_at=datetime.datetime.now(), scores=[ - Statistics( + MilestoneAgeScore( age_months=age, avg_score=avg[age], stddev_score=stddev[age], @@ -284,33 +285,33 @@ def calculate_milestone_statistics_by_age( ) -def calculate_milestonegroup_statistics_by_age( - session: SessionDep, - milestonegroup_id, - answers: Sequence[MilestoneAnswer] | None = None, -) -> MilestoneGroupAgeScores: - child_ages = _get_answer_session_child_ages_in_months(session) - - if answers is None: - answers = session.exec( - select(MilestoneAnswer).where( - col(MilestoneAnswer.milestone_group_id) == milestonegroup_id - ) - ).all() - - avg, stddev = _get_statistics_by_age(answers, child_ages) - return MilestoneGroupAgeScores( - milestonegroup_id=milestonegroup_id, - scores=[ - Statistics( - age_months=age, - avg_score=avg[age], - stddev_score=stddev[age], - ) - for age in range(0, len(avg)) - ], - created_at=datetime.datetime.now(), - ) +# def calculate_milestonegroup_statistics_by_age( +# session: SessionDep, +# milestonegroup_id, +# answers: Sequence[MilestoneAnswer] | None = None, +# ) -> MilestoneGroupAgeScores: +# child_ages = _get_answer_session_child_ages_in_months(session) + +# if answers is None: +# answers = session.exec( +# select(MilestoneAnswer).where( +# col(MilestoneAnswer.milestone_group_id) == milestonegroup_id +# ) +# ).all() + +# avg, stddev = _get_statistics_by_age(answers, child_ages) +# return MilestoneGroupAgeScores( +# milestonegroup_id=milestonegroup_id, +# scores=[ +# Statistics( +# age_months=age, +# avg_score=avg[age], +# stddev_score=stddev[age], +# ) +# for age in range(0, len(avg)) +# ], +# created_at=datetime.datetime.now(), +# ) def child_image_path(child_id: int | None) -> pathlib.Path: diff --git a/mondey_backend/tests/utils/test_scores.py b/mondey_backend/tests/utils/test_scores.py index 226e1184..6d74f2c2 100644 --- a/mondey_backend/tests/utils/test_scores.py +++ b/mondey_backend/tests/utils/test_scores.py @@ -3,22 +3,14 @@ from mondey_backend.models.children import Child from mondey_backend.models.milestones import MilestoneAgeScore from mondey_backend.models.milestones import MilestoneAnswerSession -from mondey_backend.routers.scores import ( - compute_detailed_milestonegroup_feedback_for_all_sessions, -) from mondey_backend.routers.scores import ( compute_detailed_milestonegroup_feedback_for_answersession, ) from mondey_backend.routers.scores import compute_feedback_simple -from mondey_backend.routers.scores import ( - compute_summary_milestonegroup_feedback_for_all_sessions, -) from mondey_backend.routers.scores import ( compute_summary_milestonegroup_feedback_for_answersession, ) from mondey_backend.routers.scores import get_milestonegroups_for_answersession -from mondey_backend.routers.utils import _session_has_expired -from mondey_backend.users import fastapi_users def test_get_milestonegroups_for_answersession(session): @@ -91,54 +83,3 @@ def test_compute_summary_milestonegroup_feedback_for_answersession_no_data(sessi ) assert result == {} - - -def test_compute_summary_milestonegroup_feedback_for_all_sessions(session): - child = session.exec(select(Child).where(Child.user_id == 3)).first() - - result = compute_summary_milestonegroup_feedback_for_all_sessions( - session, child, age_limit_low=6, age_limit_high=6 - ) - - relevant_answersession = list( - filter( - lambda a: _session_has_expired(a), - session.exec( - select(MilestoneAnswerSession) - .where(MilestoneAnswerSession.child_id == child.id) - .where(MilestoneAnswerSession.user_id == 3) - ).all(), - ) - ) - expected_result = { - answersession.created_at.strftime("%d-%m-%Y"): {1: 0} - for answersession in relevant_answersession - } - assert len(result) == len(relevant_answersession) - - assert result == expected_result - - -def test_compute_detailed_milestonegroup_feedback_for_all_sessions(session): - child = session.exec(select(Child).where(Child.user_id == 3)).first() - user = fastapi_users.current_user(active=True) - - result = compute_detailed_milestonegroup_feedback_for_all_sessions( - session, user, child - ) - relevant_answersession = list( - filter( - lambda a: _session_has_expired(a), - session.exec( - select(MilestoneAnswerSession) - .where(MilestoneAnswerSession.child_id == child.id) - .where(MilestoneAnswerSession.user_id == 3) - ).all(), - ) - ) - expected_result = { - answersession.created_at.strftime("%d-%m-%Y"): {1: {1: 0, 2: 0}} - for answersession in relevant_answersession - } - assert len(result) == len(relevant_answersession) - assert result == expected_result diff --git a/mondey_backend/tests/utils/test_utils.py b/mondey_backend/tests/utils/test_utils.py index 7fbe5d6f..53f20926 100644 --- a/mondey_backend/tests/utils/test_utils.py +++ b/mondey_backend/tests/utils/test_utils.py @@ -6,9 +6,8 @@ from mondey_backend.models.milestones import MilestoneAnswerSession from mondey_backend.models.milestones import MilestoneGroup from mondey_backend.routers.utils import _get_answer_session_child_ages_in_months -from mondey_backend.routers.utils import _get_score_statistics_by_age +from mondey_backend.routers.utils import _get_statistics_by_age from mondey_backend.routers.utils import calculate_milestone_statistics_by_age -from mondey_backend.routers.utils import calculate_milestonegroup_statistics from mondey_backend.routers.utils import get_milestonegroups_for_answersession @@ -39,7 +38,7 @@ def test_get_score_statistics_by_age(session): answers = session.exec(select(MilestoneAnswer)).all() child_ages = {1: 5, 2: 3, 3: 8} - avg, stddev = _get_score_statistics_by_age(answers, child_ages) + avg, stddev = _get_statistics_by_age(answers, child_ages) assert isclose(avg[5], 1.5) assert isclose(avg[3], 3.5) @@ -76,13 +75,13 @@ def test_get_score_statistics_by_age(session): ) child_ages = {} # no answer sessions ==> empty child ages - avg, stddev = _get_score_statistics_by_age(answers, child_ages) + avg, stddev = _get_statistics_by_age(answers, child_ages) assert np.all(np.isclose(avg, 0)) assert np.all(np.isclose(stddev, 0)) child_ages = {1: 5, 2: 3, 3: 8} answers = [] # no answers ==> empty answers - avg, stddev = _get_score_statistics_by_age(answers, child_ages) + avg, stddev = _get_statistics_by_age(answers, child_ages) assert np.all(np.isclose(avg, 0)) assert np.all(np.isclose(stddev, 0)) @@ -126,9 +125,10 @@ def test_calculate_milestonegroup_statistics(session): for a in session.exec(select(MilestoneAnswer)).all() if a.milestone_id in milestones ] - score = calculate_milestonegroup_statistics( - session, milestone_group.id, age, age_lower, age_upper - ) + # score = calculate_milestonegroup_statistics_by_age( + # session, milestone_group.id, + # ) + score = {} assert score.age_months == 8 assert score.group_id == 1 assert np.isclose(score.avg_score, np.mean(np.array(answers) + 1))