File size: 6,817 Bytes
1676aa7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
"""
Need Profile Engine — 100% deterministic.

This module is the boundary between "AI perception" and "AI-free math".
Nothing here calls a model. Every function is pure and unit-testable.

PRIORITY SCORE FORMULA (documented per project requirement):

    priority_raw =
          0.30 * severity              (0-10 scale)
        + 0.25 * people_affected_norm  (min-max normalized across today's scenario, 0-10 scale)
        + 0.25 * urgency               (0-10 scale)
        + 0.20 * need_avg              (mean of medical/rescue/supply need, 0-10 scale)

    priority_score = round( priority_raw / 10 * 100 )   -> 0-100

Rationale for weights: severity and urgency are weighted highest (0.30 / 0.25)
because they represent immediate life-safety risk. Population affected is
normalized rather than used raw so that one location with 10x the population
of another doesn't mechanically dominate every other signal. Need-type
average is weighted lowest because it mostly reflects *what kind* of help
is needed, not *how badly*.

These weights are a design choice, not a law of nature — they are kept in
one place (WEIGHTS below) specifically so they can be inspected, challenged,
and changed without touching any other file.
"""

from __future__ import annotations
from core.schemas import VisionAssessment, ReportExtraction, NeedProfile

WEIGHTS = {
    "severity": 0.30,
    "population": 0.25,
    "urgency": 0.25,
    "need_avg": 0.20,
}

# Deterministic mapping: HF classifier label -> (damage_type, base_severity_at_100pct_confidence)
# base_severity is what severity would be if the classifier were 100% confident;
# actual severity scales down toward a neutral midpoint as confidence drops,
# so a low-confidence classification never produces a falsely extreme score.
CLASSIFIER_LABEL_MAP = {
    "Human_Damage": ("human_casualties", 9.5),
    "Fire_Disaster": ("fire", 9.0),
    "Water_Disaster": ("flooding", 7.5),
    "Land_Disaster": ("road_damage", 6.5),
    "Damaged_Infrastructure": ("structural_collapse", 8.5),
    "Non_Damage": ("no_significant_damage", 1.0),
}

NEUTRAL_SEVERITY = 5.0  # what we fall back toward when the model is unsure


def score_vision_severity(classifier_label: str, confidence: float) -> tuple[str, float]:
    """
    Deterministic conversion of a classifier label + confidence into
    (damage_type, severity_score). The MODEL only supplies label+confidence;
    THIS FUNCTION decides the number, and it's a fixed, inspectable formula.
    """
    damage_type, base_severity = CLASSIFIER_LABEL_MAP.get(
        classifier_label, ("structural_collapse", NEUTRAL_SEVERITY)
    )
    confidence = max(0.0, min(1.0, confidence))
    # Linear interpolation between neutral (low confidence) and base (high confidence)
    severity = NEUTRAL_SEVERITY + (base_severity - NEUTRAL_SEVERITY) * confidence
    return damage_type, round(severity, 2)


def _minmax_norm(value: float, all_values: list[float]) -> float:
    """Normalize `value` against the range of `all_values` onto a 0-10 scale."""
    lo, hi = min(all_values), max(all_values)
    if hi == lo:
        return 5.0  # everyone equal -> neutral midpoint, avoids div-by-zero
    return (value - lo) / (hi - lo) * 10.0


def build_need_profiles(
    vision_by_location: dict[str, VisionAssessment],
    report_by_location: dict[str, ReportExtraction],
    display_names: dict[str, str],
    coordinates: dict[str, tuple[float, float]],
) -> list[NeedProfile]:
    """
    Merge vision + report data per location into NeedProfile objects,
    with the priority_score computed by the documented formula above.
    Every location that has EITHER a vision assessment OR a report is included;
    missing fields fall back to conservative (low-priority) defaults rather
    than crashing, per the failure-handling requirement.
    """
    location_ids = set(vision_by_location) | set(report_by_location)
    all_people = [report_by_location[loc].people_affected for loc in location_ids
                  if loc in report_by_location] or [0]

    profiles = []
    for loc in location_ids:
        vision = vision_by_location.get(loc)
        report = report_by_location.get(loc)

        severity = vision.severity_score if vision else NEUTRAL_SEVERITY
        damage_type = vision.damage_type if vision else "structural_collapse"
        caption = vision.caption if vision else "No image evidence available."

        people = report.people_affected if report else 0
        urgency_report = report.urgency if report else 5.0
        # Blend: if we have both signals, urgency is the average of report-stated
        # urgency and vision severity (a picture of collapse implies urgency
        # even if the text report under-states it). If only one exists, use it.
        urgency = (urgency_report + severity) / 2 if (vision and report) else (
            urgency_report if report else severity
        )

        need_types = set(report.need_types) if report else set()
        medical_need = 8.0 if "medical" in need_types else (severity * 0.3 if damage_type == "human_casualties" else 2.0)
        rescue_need = 8.0 if "rescue" in need_types else (severity * 0.4 if damage_type in ("structural_collapse", "flooding") else 2.0)
        supply_need = 6.0 if "supply" in need_types else 2.0
        need_avg = (medical_need + rescue_need + supply_need) / 3

        pop_norm = _minmax_norm(people, all_people)

        priority_raw = (
            WEIGHTS["severity"] * severity
            + WEIGHTS["population"] * pop_norm
            + WEIGHTS["urgency"] * urgency
            + WEIGHTS["need_avg"] * need_avg
        )
        priority_score = round(priority_raw / 10 * 100, 1)

        lat, lon = coordinates.get(loc, (0.0, 0.0))

        profiles.append(NeedProfile(
            location_id=loc,
            display_name=display_names.get(loc, loc),
            severity=round(severity, 2),
            people_affected=people,
            urgency=round(urgency, 2),
            medical_need=round(medical_need, 2),
            rescue_need=round(rescue_need, 2),
            supply_need=round(supply_need, 2),
            priority_score=priority_score,
            required_medical_teams=report.requested_medical_teams if report else (2 if medical_need > 5 else 0),
            required_rescue_teams=report.requested_rescue_teams if report else (2 if rescue_need > 5 else 0),
            required_supply_trucks=report.requested_supply_trucks if report else (1 if supply_need > 5 else 0),
            damage_type=damage_type,
            evidence_caption=caption,
            lat=lat,
            lon=lon,
        ))

    # Highest priority first — this ordering is used directly by the dashboard's
    # "Priority Ranking" panel.
    profiles.sort(key=lambda p: p.priority_score, reverse=True)
    return profiles