import random
from dataclasses import dataclass
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import gradio as gr
import spaces
# ============================================================
# APPLICATION CONFIGURATION
# ============================================================
APP_TITLE = "Hearing Technology Behaviour Lab"
APP_VERSION = "0.5.1"
DISCLAIMER = (
"Synthetic behavioural simulation for research and exploration. "
"It is not a clinical prediction model and does not represent "
"individual real-world patients."
)
# ============================================================
# EVIDENCE / RAG CONTEXT
# ============================================================
EVIDENCE = {
"directionality": {
"label": "Directional microphone benefit",
"description": (
"Laboratory studies commonly report roughly 3–4 dB directional "
"benefit in difficult near-field conditions; individual studies "
"also report values around 2–5 dB depending on configuration."
),
"source": "Ricketts review; Kim & Kim study; hearing-aid directionality literature",
"tier": "A — empirical anchor",
"default": "Fixed directional prior = 3.2 dB",
},
"bilateral_directionality": {
"label": "Bilateral spatial processing",
"description": (
"Bilateral directional processing can provide larger SNR advantages "
"than a single first-order directional microphone under favourable conditions."
),
"source": "Ricketts et al. / hearing-aid prescription literature",
"tier": "A/B — empirical anchor with configuration dependence",
"default": "Binaural beamformer prior = 5.5 dB",
},
"adaptive_directionality": {
"label": "Adaptive directionality",
"description": (
"Adaptive directional processing improves speech recognition over "
"omnidirectional processing in several simulated real-world scenes, "
"while the realised advantage depends on noise movement and geometry."
),
"source": "Evaluation of adaptive directional-microphone hearing aids",
"tier": "A/B — empirical anchor with scene dependence",
"default": "Adaptive directional prior = 3.8 dB",
},
"dnn": {
"label": "DNN / learned noise reduction",
"description": (
"Recent controlled work reports benefits from DNN processing, particularly "
"when combined with beamforming, but outcomes vary with noise type and "
"spatial configuration. The model therefore uses a broad prior rather than "
"a single claimed DNN gain."
),
"source": "Recent DNN hearing-aid speech-recognition/listening-effort literature",
"tier": "B — literature-informed",
"default": "DNN beamformer prior = 4.8 dB",
},
"listening_effort": {
"label": "Listening effort",
"description": (
"Directional microphone processing has been shown to reduce listening effort "
"in controlled speech-in-noise tasks."
),
"source": "Picou & Ricketts; Desjardins",
"tier": "A — empirical direction of effect",
"default": "Effort responds to effective SNR and task difficulty",
},
"sound_quality": {
"label": "Signal fidelity / sound quality",
"description": (
"Noise reduction can improve effective SNR while also introducing signal "
"distortion; stronger processing can therefore create a performance–fidelity trade-off."
),
"source": "Hearing-aid noise-reduction / HASQI literature",
"tier": "A/B — empirical direction of effect",
"default": "Processing artefact penalty rises with processing intensity",
},
"satisfaction": {
"label": "Satisfaction",
"description": (
"MarkeTrak 2022 reported 83% overall hearing-aid satisfaction, broadly "
"consistent with 81% in 2015 and 83% in 2019."
),
"source": "MarkeTrak 2022",
"tier": "A — population anchor",
"default": "Population-level satisfaction anchor ≈ 0.83",
},
"daily_use": {
"label": "Daily use",
"description": (
"MarkeTrak provides a useful real-world anchor for daily use and hours worn, "
"while individual use remains heterogeneous."
),
"source": "MarkeTrak survey literature",
"tier": "A — population anchor",
"default": "Daily-use prior ≈ 0.67–0.70; wear time ≈ 8–10 h/day",
},
"wearability": {
"label": "Wearability / practical burden",
"description": (
"Comfort, handling, visibility, charging and technology complexity are modelled "
"as behavioural modifiers rather than acoustic effects."
),
"source": "Behavioural modelling informed by hearing-aid user research",
"tier": "C — modelling construct",
"default": "Explicitly synthetic; not a prevalence estimate",
},
"spatial_performance": {
"label": "Spatial geometry",
"description": (
"Directional benefit depends on target/noise geometry, source distance, "
"reverberation and movement; frontal target access is generally more favourable "
"for conventional directional processing."
),
"source": "Directional microphone and spatial-processing literature",
"tier": "A/B — evidence-informed mechanism",
"default": "Scene-specific geometry modifier",
},
"intelligibility": {
"label": "Estimated intelligibility",
"description": (
"The simulation uses a logistic psychometric proxy so that an identical SNR "
"change can produce different percentage-point gains depending on starting difficulty."
),
"source": "Modelled functional construct",
"tier": "C — modelling construct",
"default": "Not a clinical speech-recognition score",
},
"glasses": {
"label": "Augmented listening glasses",
"description": (
"Hypothetical technology. There is no single empirical parameter set in the "
"literature that justifies treating this as an established device class."
),
"source": "Synthetic scenario / hypothesis",
"tier": "C — hypothetical",
"default": "Conservative 4.0 dB potential prior; wide uncertainty",
},
}
# ============================================================
# TECHNOLOGIES
# ============================================================
REFERENCE_CONDITIONS = [
"Unaided listening",
"Omnidirectional",
"Fixed directional",
"Adaptive directional",
"Beamformer",
"Binaural beamformer",
"DNN beamformer",
"Augmented listening glasses",
]
TECHNOLOGIES = {
"Omnidirectional": {
"potential_snr_db": 0.0,
"off_axis_robustness": 1.00,
"spatial_selectivity": 0.05,
"target_preservation": 0.98,
"mobility_robustness": 1.00,
"reverb_robustness": 1.00,
"competing_speech_robustness": 0.55,
"processing_intensity": 0.02,
"naturalness": 0.98,
"battery": 0.96,
"wearability": 0.92,
"visibility": 0.05,
"complexity": 0.02,
"charging_hours": 14.0,
"description": "Broad acoustic access with minimal spatial filtering.",
"evidence_tier": "A/B baseline condition",
},
"Fixed directional": {
"potential_snr_db": 3.2,
"off_axis_robustness": 0.38,
"spatial_selectivity": 0.68,
"target_preservation": 0.92,
"mobility_robustness": 0.50,
"reverb_robustness": 0.68,
"competing_speech_robustness": 0.60,
"processing_intensity": 0.24,
"naturalness": 0.88,
"battery": 0.88,
"wearability": 0.90,
"visibility": 0.05,
"complexity": 0.18,
"charging_hours": 13.0,
"description": "Stable forward-oriented directionality; strongest when geometry is favourable.",
"evidence_tier": "A — literature-anchored prior",
},
"Adaptive directional": {
"potential_snr_db": 3.8,
"off_axis_robustness": 0.64,
"spatial_selectivity": 0.72,
"target_preservation": 0.92,
"mobility_robustness": 0.72,
"reverb_robustness": 0.66,
"competing_speech_robustness": 0.66,
"processing_intensity": 0.34,
"naturalness": 0.87,
"battery": 0.80,
"wearability": 0.88,
"visibility": 0.05,
"complexity": 0.32,
"charging_hours": 12.5,
"description": "Directionality adapts to changing acoustic scenes and moving noise.",
"evidence_tier": "A/B — literature-anchored prior",
},
"Beamformer": {
"potential_snr_db": 4.5,
"off_axis_robustness": 0.68,
"spatial_selectivity": 0.80,
"target_preservation": 0.90,
"mobility_robustness": 0.68,
"reverb_robustness": 0.64,
"competing_speech_robustness": 0.68,
"processing_intensity": 0.48,
"naturalness": 0.82,
"battery": 0.72,
"wearability": 0.84,
"visibility": 0.05,
"complexity": 0.50,
"charging_hours": 11.5,
"description": "Stronger spatial target extraction with greater processing burden.",
"evidence_tier": "B — engineering/literature-informed prior",
},
"Binaural beamformer": {
"potential_snr_db": 5.5,
"off_axis_robustness": 0.76,
"spatial_selectivity": 0.86,
"target_preservation": 0.92,
"mobility_robustness": 0.76,
"reverb_robustness": 0.67,
"competing_speech_robustness": 0.74,
"processing_intensity": 0.60,
"naturalness": 0.83,
"battery": 0.65,
"wearability": 0.82,
"visibility": 0.05,
"complexity": 0.62,
"charging_hours": 10.5,
"description": "Uses bilateral information for stronger spatial target extraction.",
"evidence_tier": "A/B — literature-informed prior",
},
"DNN beamformer": {
"potential_snr_db": 4.8,
"off_axis_robustness": 0.70,
"spatial_selectivity": 0.88,
"target_preservation": 0.89,
"mobility_robustness": 0.66,
"reverb_robustness": 0.72,
"competing_speech_robustness": 0.78,
"processing_intensity": 0.76,
"naturalness": 0.77,
"battery": 0.55,
"wearability": 0.79,
"visibility": 0.05,
"complexity": 0.78,
"charging_hours": 9.5,
"description": "Learned spatial/noise separation; potentially strong but scene-dependent.",
"evidence_tier": "B — recent literature-informed prior",
},
"Augmented listening glasses": {
"potential_snr_db": 4.0,
"off_axis_robustness": 0.82,
"spatial_selectivity": 0.90,
"target_preservation": 0.90,
"mobility_robustness": 0.78,
"reverb_robustness": 0.68,
"competing_speech_robustness": 0.72,
"processing_intensity": 0.72,
"naturalness": 0.70,
"battery": 0.48,
"wearability": 0.62,
"visibility": 0.45,
"complexity": 0.78,
"charging_hours": 8.0,
"description": "Hypothetical wearable augmented-listening technology; parameters are deliberately uncertain.",
"evidence_tier": "C — hypothetical scenario",
},
}
# ============================================================
# LISTENING SCENES
# ============================================================
SCENES = {
"Quiet conversation": {
"noise": 0.10, "reverb": 0.10, "distance": 0.20, "spatial": 0.30,
"social": 0.75, "importance": 0.75,
"target_azimuth": 10, "target_mobility": 0.10, "competing_speech": 0.05,
},
"Family dinner": {
"noise": 0.55, "reverb": 0.35, "distance": 0.45, "spatial": 0.65,
"social": 0.90, "importance": 0.90,
"target_azimuth": 35, "target_mobility": 0.35, "competing_speech": 0.55,
},
"Busy restaurant": {
"noise": 0.85, "reverb": 0.65, "distance": 0.65, "spatial": 0.80,
"social": 0.90, "importance": 0.90,
"target_azimuth": 70, "target_mobility": 0.55, "competing_speech": 0.80,
},
"Work meeting": {
"noise": 0.35, "reverb": 0.40, "distance": 0.55, "spatial": 0.75,
"social": 0.80, "importance": 0.85,
"target_azimuth": 20, "target_mobility": 0.25, "competing_speech": 0.45,
},
"Walking outdoors": {
"noise": 0.35, "reverb": 0.05, "distance": 0.70, "spatial": 0.95,
"social": 0.35, "importance": 0.60,
"target_azimuth": 110, "target_mobility": 0.75, "competing_speech": 0.10,
},
"Public transport": {
"noise": 0.75, "reverb": 0.55, "distance": 0.65, "spatial": 0.75,
"social": 0.35, "importance": 0.55,
"target_azimuth": 65, "target_mobility": 0.25, "competing_speech": 0.60,
},
"Lecture / presentation": {
"noise": 0.25, "reverb": 0.55, "distance": 0.85, "spatial": 0.60,
"social": 0.65, "importance": 0.80,
"target_azimuth": 5, "target_mobility": 0.10, "competing_speech": 0.05,
},
"Social gathering": {
"noise": 0.70, "reverb": 0.45, "distance": 0.60, "spatial": 0.85,
"social": 1.00, "importance": 0.95,
"target_azimuth": 55, "target_mobility": 0.60, "competing_speech": 0.75,
},
}
# ============================================================
# SYNTHETIC USER
# ============================================================
@dataclass
class SyntheticUser:
user_id: int
hearing_loss: float
noise_difficulty: float
spatial_difficulty: float
technology_tolerance: float
comfort_sensitivity: float
social_confidence: float
engagement: float
battery_sensitivity: float
visibility_sensitivity: float
baseline_use: float
baseline_satisfaction: float
acceptance: float = 0.50
fatigue: float = 0.0
cumulative_benefit: float = 0.0
def create_user(user_id):
return SyntheticUser(
user_id=user_id,
hearing_loss=float(np.clip(np.random.normal(0.55, 0.18), 0.15, 0.95)),
noise_difficulty=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)),
spatial_difficulty=float(np.clip(np.random.normal(0.45, 0.22), 0.05, 0.95)),
technology_tolerance=float(np.clip(np.random.normal(0.60, 0.20), 0.10, 0.95)),
comfort_sensitivity=float(np.clip(np.random.normal(0.55, 0.20), 0.10, 0.95)),
social_confidence=float(np.clip(np.random.normal(0.65, 0.18), 0.10, 0.95)),
engagement=float(np.clip(np.random.normal(0.65, 0.20), 0.10, 0.95)),
battery_sensitivity=float(np.clip(np.random.normal(0.50, 0.20), 0.10, 0.95)),
visibility_sensitivity=float(np.clip(np.random.normal(0.40, 0.25), 0.05, 0.95)),
# Calibrated loosely around MarkeTrak's population-level daily-use anchor,
# but individual values remain synthetic and are not prevalence estimates.
baseline_use=float(np.clip(np.random.normal(0.69, 0.14), 0.25, 0.98)),
baseline_satisfaction=float(np.clip(np.random.normal(0.83, 0.10), 0.45, 0.98)),
)
# ============================================================
# TECHNOLOGY EFFECT
# ============================================================
def _technology_profile(condition):
"""Return a technology profile. Unaided is an explicit zero-processing reference."""
if condition == "Unaided listening":
return {
"noise": 0.0, "spatial": 0.0, "target": 0.0,
"naturalness": 1.0, "battery": 1.0, "complexity": 0.0,
"snr_cap_db": 0.0, "spatial_selectivity": 0.0,
"target_preservation": 1.0, "off_axis_robustness": 1.0,
"wearability": 1.0, "visibility": 0.0,
}
return TECHNOLOGIES[condition]
def _angle_alignment(target_azimuth):
"""Conventional forward-axis alignment, 1 at 0° and 0 at 90°/180°."""
angle = abs(float(target_azimuth))
# Use a smooth front-to-side/back decay rather than a negative cosine.
return float(np.clip(np.cos(np.deg2rad(min(angle, 90))) ** 1.25, 0, 1))
def _scene_noise_type(scene):
if scene in {"Busy restaurant", "Social gathering", "Family dinner"}:
return "multi_talker"
if scene in {"Public transport", "Walking outdoors"}:
return "environmental"
if scene in {"Work meeting", "Lecture / presentation"}:
return "speech_dominant"
return "low_competition"
def _acoustic_scene_state(scene, user):
s = SCENES[scene]
# A transparent SNR proxy. It is intentionally not a clinical/laboratory
# measurement; it creates a common acoustic state for the technology arms.
unaided_snr = (
8.0
- 7.0 * s["noise"]
- 3.0 * s["reverb"]
- 2.0 * s["distance"]
- 2.0 * s["competing_speech"]
- 2.5 * user.noise_difficulty
)
return {
"unaided_snr_db": float(unaided_snr),
"target_azimuth": float(s["target_azimuth"]),
"target_mobility": float(s["target_mobility"]),
"competing_speech": float(s["competing_speech"]),
"reverb": float(s["reverb"]),
"distance": float(s["distance"]),
"noise": float(s["noise"]),
"spatial_difficulty": float(np.clip(
0.55 * s["spatial"] + 0.45 * user.spatial_difficulty, 0, 1
)),
"noise_type": _scene_noise_type(scene),
}
def _psychometric_intelligibility(snr_db, hearing_loss):
"""
Logistic psychometric proxy.
This is deliberately not labelled as a clinical speech-intelligibility
function. Its purpose is to make SNR effects nonlinear: the same dB
improvement can produce different percentage-point gains depending on
starting difficulty.
"""
threshold = 1.5 + 5.0 * hearing_loss
slope = 2.6
return float(1.0 / (1.0 + np.exp(-(snr_db - threshold) / slope)))
def technology_effect(technology, scene, user):
"""
Evidence-informed layered model.
Acoustic:
potential SNR -> geometry -> reverberation -> movement -> scene type
Functional:
effective SNR -> nonlinear intelligibility -> listening access
Experience:
sound quality -> effort -> comfort/wearability/charging
Technology values are simulation priors, not commercial specifications.
"""
tech = _technology_profile(technology)
s = SCENES[scene]
state = _acoustic_scene_state(scene, user)
az = state["target_azimuth"]
alignment = _angle_alignment(az)
# Geometry: conventional directionality is strongest for frontal targets.
geometry_factor = (
0.30
+ 0.70 * (
tech["off_axis_robustness"]
+ alignment * (1.0 - tech["off_axis_robustness"])
)
)
# Spatial opportunity is greatest when there is meaningful competing sound.
spatial_opportunity = float(np.clip(
0.15
+ 0.65 * s["noise"]
+ 0.35 * s["competing_speech"]
+ 0.15 * s["spatial"],
0, 1
))
# Reverberation reduces realised spatial separation.
reverb_factor = float(np.clip(
1.0 - s["reverb"] * (1.0 - tech["reverb_robustness"]) * 0.85,
0.35, 1.0
))
mobility_factor = float(np.clip(
1.0 - s["target_mobility"] * (1.0 - tech["mobility_robustness"]) * 0.55,
0.40, 1.0
))
# DNNs are explicitly scene-dependent rather than universally superior.
noise_type_factor = 1.0
if technology == "DNN beamformer":
noise_type_factor = {
"multi_talker": 1.05,
"speech_dominant": 1.00,
"environmental": 0.92,
"low_competition": 0.65,
}[state["noise_type"]]
if technology == "Augmented listening glasses":
# Hypothetical system: spatial access is a potential strength, but
# placement/orientation and real-world coupling are uncertain.
noise_type_factor = 0.92 if state["noise_type"] == "environmental" else 1.0
snr_gain_db = (
tech["potential_snr_db"]
* spatial_opportunity
* geometry_factor
* reverb_factor
* mobility_factor
* noise_type_factor
)
# Individual spatial difficulty makes complex scenes less predictable.
snr_gain_db *= (0.92 - 0.15 * user.spatial_difficulty)
# Unaided is a true zero-processing reference.
if technology == "Unaided listening":
snr_gain_db = 0.0
effective_snr_db = state["unaided_snr_db"] + snr_gain_db
unaided_intelligibility = _psychometric_intelligibility(
state["unaided_snr_db"], user.hearing_loss
)
intelligibility = _psychometric_intelligibility(
effective_snr_db, user.hearing_loss
)
intelligibility_gain = float(
np.clip(intelligibility - unaided_intelligibility, -1, 1)
)
target_preservation = float(np.clip(
tech["target_preservation"]
- 0.12 * state["competing_speech"] * (1.0 - tech["competing_speech_robustness"])
- 0.08 * (1.0 - geometry_factor),
0, 1
))
spatial_robustness = float(np.clip(
0.35
+ 0.40 * tech["off_axis_robustness"]
+ 0.25 * tech["mobility_robustness"]
- 0.18 * state["spatial_difficulty"] * (1.0 - tech["off_axis_robustness"]),
0, 1
))
listening_access = float(np.clip(
0.55 * intelligibility
+ 0.20 * target_preservation
+ 0.15 * spatial_robustness
+ 0.10 * (1.0 - s["reverb"]),
0, 1
))
# Signal-quality trade-off: stronger processing can raise SNR while
# introducing artefact / fidelity penalties.
artefact_burden = float(np.clip(
tech["processing_intensity"]
* (0.35 + 0.65 * s["noise"])
* (1.05 - tech["naturalness"])
+ 0.08 * tech["processing_intensity"] * s["competing_speech"],
0, 1
))
sound_quality = float(np.clip(
tech["naturalness"] - artefact_burden,
0.35, 1.0
))
perceived_benefit = float(np.clip(
0.45 * listening_access
+ 0.25 * np.clip(0.50 + 1.6 * intelligibility_gain, 0, 1)
+ 0.15 * sound_quality
+ 0.15 * target_preservation,
0, 1
))
battery_burden = float(np.clip(
(1.0 - tech["battery"])
* user.battery_sensitivity
* (0.65 + 0.35 * s["noise"]),
0, 1
))
charging_burden = float(np.clip(
battery_burden * (0.65 + 0.35 * tech["complexity"]),
0, 1
))
complexity_burden = float(
tech["complexity"] * (1.0 - user.technology_tolerance)
)
comfort = float(np.clip(
0.91
- 0.16 * complexity_burden
- 0.12 * battery_burden
- 0.18 * (1.0 - tech["wearability"]) * user.comfort_sensitivity
- 0.12 * tech["visibility"] * user.visibility_sensitivity,
0.20, 0.98
))
wearability = float(np.clip(
tech["wearability"]
- 0.10 * user.comfort_sensitivity * (1.0 - tech["wearability"])
- 0.10 * tech["visibility"] * user.visibility_sensitivity,
0, 1
))
return {
"benefit": perceived_benefit,
"perceived_benefit": perceived_benefit,
"snr_gain_db": float(max(0.0, snr_gain_db)),
"effective_snr_db": float(effective_snr_db),
"unaided_snr_db": float(state["unaided_snr_db"]),
"intelligibility": float(np.clip(intelligibility, 0, 1)),
"unaided_intelligibility": float(np.clip(unaided_intelligibility, 0, 1)),
"intelligibility_gain": intelligibility_gain,
"target_preservation": target_preservation,
"spatial_robustness": spatial_robustness,
"listening_access": listening_access,
"sound_quality": sound_quality,
"artefact_burden": artefact_burden,
"comfort": comfort,
"wearability": wearability,
"battery_burden": battery_burden,
"charging_burden": charging_burden,
"complexity_burden": complexity_burden,
"target_azimuth": az,
"geometry_factor": float(geometry_factor),
"reverb_factor": float(reverb_factor),
"mobility_factor": float(mobility_factor),
"noise_type": state["noise_type"],
}
def simulate_user_day(
user,
technology,
scene,
temperature=1.0,
randomness=0.10
):
scene_data = SCENES[scene]
effects = technology_effect(technology, scene, user)
perceived_help = float(np.clip(
effects["perceived_benefit"]
* (0.65 + 0.45 * user.hearing_loss)
* (0.70 + 0.30 * user.engagement),
0, 1
))
# Effort is driven by difficulty that remains after processing.
listening_effort = float(np.clip(
0.48 * scene_data["noise"] * user.noise_difficulty
+ 0.22 * (1.0 - effects["intelligibility"])
+ 0.12 * (1.0 - effects["spatial_robustness"])
+ 0.08 * effects["artefact_burden"]
+ 0.10 * effects["complexity_burden"],
0, 1
))
fatigue = float(np.clip(
0.58 * listening_effort
+ 0.14 * (1.0 - effects["comfort"])
+ 0.12 * (1.0 - effects["wearability"])
+ 0.10 * effects["charging_burden"]
+ 0.06 * effects["artefact_burden"],
0, 1
))
social_participation = float(np.clip(
0.35
+ 0.35 * perceived_help
+ 0.20 * user.social_confidence
+ 0.15 * effects["intelligibility"]
- 0.35 * fatigue,
0, 1
))
# Daily use is behavioural: benefit helps, burden suppresses use.
use_probability = (
user.baseline_use
+ 0.22 * perceived_help
+ 0.14 * effects["comfort"]
+ 0.10 * user.technology_tolerance
- 0.16 * effects["charging_burden"]
- 0.12 * effects["complexity_burden"]
- 0.12 * user.visibility_sensitivity * (1.0 - effects["wearability"])
- 0.15 * fatigue
)
if technology == "Unaided listening":
use_probability = 1.0
use_probability = float(np.clip(
use_probability / max(temperature, 0.05), 0, 1
))
used = int(np.random.random() < use_probability)
# Approximate daily wear time. This is a behavioural simulation variable,
# not a measured distribution for a specific device.
if technology == "Unaided listening":
wear_hours = 0.0
else:
tech = _technology_profile(technology)
expected_hours = (
7.5
+ 2.0 * use_probability
+ 0.8 * perceived_help
- 1.5 * effects["charging_burden"]
- 1.0 * fatigue
)
max_hours = tech.get("charging_hours", 10.0)
wear_hours = float(np.clip(
expected_hours + np.random.normal(0, 0.65 * (1 + randomness)),
0, max_hours
))
if not used:
wear_hours *= 0.20
if not used and technology != "Unaided listening":
return {
"used": 0,
"benefit": 0.0,
"perceived_benefit": 0.0,
"snr_gain_db": effects["snr_gain_db"],
"effective_snr_db": effects["effective_snr_db"],
"unaided_snr_db": effects["unaided_snr_db"],
"intelligibility": effects["intelligibility"] * 0.65,
"unaided_intelligibility": effects["unaided_intelligibility"],
"intelligibility_gain": effects["intelligibility_gain"] * 0.65,
"target_preservation": effects["target_preservation"],
"spatial_robustness": effects["spatial_robustness"],
"listening_access": effects["listening_access"] * 0.65,
"sound_quality": effects["sound_quality"],
"artefact_burden": effects["artefact_burden"],
"comfort": effects["comfort"] * 0.85,
"wearability": effects["wearability"] * 0.85,
"battery_burden": effects["battery_burden"],
"charging_burden": effects["charging_burden"],
"complexity_burden": effects["complexity_burden"],
"listening_effort": listening_effort,
"fatigue": fatigue * 0.55,
"social_participation": social_participation * 0.55,
"listening_success": float(np.clip(0.35 * effects["intelligibility"], 0, 1)),
"acceptance_change": -0.010 * fatigue,
"wear_hours": wear_hours,
"target_azimuth": effects["target_azimuth"],
"geometry_factor": effects["geometry_factor"],
}
listening_success = float(np.clip(
0.12
+ 0.58 * effects["intelligibility"]
+ 0.18 * effects["target_preservation"]
+ 0.12 * effects["spatial_robustness"]
- 0.22 * listening_effort,
0, 1
))
acceptance_change = (
0.020 * perceived_help
+ 0.012 * effects["comfort"]
+ 0.010 * effects["wearability"]
+ 0.006 * effects["sound_quality"]
- 0.018 * fatigue
- 0.010 * effects["charging_burden"]
)
acceptance_change += np.random.normal(
0, randomness * 0.015
)
return {
"used": 1,
"benefit": perceived_help,
"perceived_benefit": perceived_help,
"snr_gain_db": effects["snr_gain_db"],
"effective_snr_db": effects["effective_snr_db"],
"unaided_snr_db": effects["unaided_snr_db"],
"intelligibility": effects["intelligibility"],
"unaided_intelligibility": effects["unaided_intelligibility"],
"intelligibility_gain": effects["intelligibility_gain"],
"target_preservation": effects["target_preservation"],
"spatial_robustness": effects["spatial_robustness"],
"listening_access": effects["listening_access"],
"sound_quality": effects["sound_quality"],
"artefact_burden": effects["artefact_burden"],
"comfort": effects["comfort"],
"wearability": effects["wearability"],
"battery_burden": effects["battery_burden"],
"charging_burden": effects["charging_burden"],
"complexity_burden": effects["complexity_burden"],
"listening_effort": listening_effort,
"fatigue": fatigue,
"social_participation": social_participation,
"listening_success": listening_success,
"acceptance_change": acceptance_change,
"wear_hours": wear_hours,
"target_azimuth": effects["target_azimuth"],
"geometry_factor": effects["geometry_factor"],
}
# ============================================================
# MAIN SIMULATION
# ============================================================
def generate_population(n_users, seed=42):
"""Create one synthetic population that can be reused across technology arms."""
np.random.seed(seed)
random.seed(seed)
return [create_user(i + 1) for i in range(int(n_users))]
def run_simulation(
n_users=20,
days=14,
technology="Adaptive directional",
scenes=None,
randomness=0.10,
engagement_weight=0.65,
social_weight=0.65,
seed=42,
users=None
):
np.random.seed(seed)
random.seed(seed)
if not scenes:
scenes = list(SCENES.keys())
if users is None:
users = generate_population(n_users, seed)
else:
users = [SyntheticUser(**vars(u)) for u in users]
records = []
for day in range(1, days + 1):
for user in users:
n_daily_scenes = min(
len(scenes),
max(2, int(len(scenes) * 0.45))
)
daily_scenes = random.sample(scenes, n_daily_scenes)
day_results = []
for scene in daily_scenes:
result = simulate_user_day(
user=user,
technology=technology,
scene=scene,
temperature=1.0 + (1.0 - engagement_weight) * 0.2,
randomness=randomness
)
user.acceptance = float(np.clip(
user.acceptance + result["acceptance_change"], 0, 1
))
user.fatigue = float(np.clip(
0.65 * user.fatigue + 0.35 * result["fatigue"], 0, 1
))
user.cumulative_benefit += result["perceived_benefit"]
day_results.append({"scene": scene, **result})
avg = pd.DataFrame(day_results).mean(numeric_only=True)
user.engagement = float(np.clip(
user.engagement
+ social_weight
* (avg["social_participation"] - 0.5)
* 0.03,
0, 1
))
records.append({
"day": day,
"user": f"User {user.user_id}",
"user_id": user.user_id,
"technology": technology,
"use": avg["used"],
"wear_hours": avg["wear_hours"],
"benefit": avg["perceived_benefit"],
"perceived_benefit": avg["perceived_benefit"],
"snr_gain_db": avg["snr_gain_db"],
"unaided_snr_db": avg["unaided_snr_db"],
"effective_snr_db": avg["effective_snr_db"],
"intelligibility": avg["intelligibility"],
"unaided_intelligibility": avg["unaided_intelligibility"],
"intelligibility_gain": avg["intelligibility_gain"],
"target_preservation": avg["target_preservation"],
"spatial_robustness": avg["spatial_robustness"],
"listening_access": avg["listening_access"],
"sound_quality": avg["sound_quality"],
"artefact_burden": avg["artefact_burden"],
"listening_effort": avg["listening_effort"],
"comfort": avg["comfort"],
"wearability": avg["wearability"],
"battery_burden": avg["battery_burden"],
"charging_burden": avg["charging_burden"],
"complexity_burden": avg["complexity_burden"],
"fatigue": avg["fatigue"],
"social_participation": avg["social_participation"],
"listening_success": avg["listening_success"],
"acceptance": user.acceptance,
"engagement": user.engagement,
"hearing_loss": user.hearing_loss,
"noise_difficulty": user.noise_difficulty,
"spatial_difficulty": user.spatial_difficulty,
"technology_tolerance": user.technology_tolerance,
"battery_sensitivity": user.battery_sensitivity,
"visibility_sensitivity": user.visibility_sensitivity,
"target_azimuth": avg["target_azimuth"],
"geometry_factor": avg["geometry_factor"],
})
return pd.DataFrame(records)
# ============================================================
# METRICS
# ============================================================
def summary_metrics(df):
return {
"use": float(df["use"].mean()),
"benefit": float(df["perceived_benefit"].mean()),
"comfort": float(df["comfort"].mean()),
"fatigue": float(df["fatigue"].mean()),
"engagement": float(df["engagement"].mean()),
"acceptance": float(df["acceptance"].mean()),
"success": float(df["listening_success"].mean()),
"social": float(df["social_participation"].mean()),
"snr_gain_db": float(df["snr_gain_db"].mean()),
"intelligibility": float(df["intelligibility"].mean()),
"intelligibility_gain": float(df["intelligibility_gain"].mean()),
"target_preservation": float(df["target_preservation"].mean()),
"spatial_robustness": float(df["spatial_robustness"].mean()),
"listening_access": float(df["listening_access"].mean()),
"wearability": float(df["wearability"].mean()),
"charging_burden": float(df["charging_burden"].mean()),
"effective_snr_db": float(df["effective_snr_db"].mean()),
"listening_effort": float(df["listening_effort"].mean()),
"sound_quality": float(df["sound_quality"].mean()),
"artefact_burden": float(df["artefact_burden"].mean()),
"wear_hours": float(df["wear_hours"].mean()),
}
def make_summary_table(df):
m = summary_metrics(df)
rows = [
("Daily condition use", m["use"], "0–1"),
("Wear time (hours/day)", m["wear_hours"], "hours"),
("Perceived benefit", m["benefit"], "0–1"),
("Comfort", m["comfort"], "0–1"),
("Listening success", m["success"], "0–1"),
("Estimated intelligibility", m["intelligibility"], "0–1"),
("Intelligibility gain vs unaided", m["intelligibility_gain"], "percentage-point proxy"),
("SNR enhancement (dB)", m["snr_gain_db"], "dB"),
("Unaided SNR proxy (dB)", float(df["unaided_snr_db"].mean()), "model proxy"),
("Effective SNR (dB)", m["effective_snr_db"], "model proxy"),
("Target preservation", m["target_preservation"], "0–1"),
("Spatial robustness", m["spatial_robustness"], "0–1"),
("Listening access", m["listening_access"], "0–1"),
("Listening effort", m["listening_effort"], "0–1; lower is better"),
("Sound quality", m["sound_quality"], "0–1"),
("Artefact burden", m["artefact_burden"], "0–1; lower is better"),
("Social participation", m["social"], "0–1"),
("Fatigue", m["fatigue"], "0–1; lower is better"),
("Engagement", m["engagement"], "0–1"),
("Acceptance", m["acceptance"], "0–1"),
("Wearability", m["wearability"], "0–1"),
("Charging burden", m["charging_burden"], "0–1; lower is better"),
]
return pd.DataFrame({
"Measure": [r[0] for r in rows],
"Score": [round(float(r[1]), 3) for r in rows],
"Interpretation": [r[2] for r in rows],
})
# ============================================================
# RADAR / BEHAVIOURAL PROFILE
# ============================================================
def radar_plot(df):
"""Create the behavioural profile radar chart used by the Overview tab.
The radar deliberately uses downstream user-experience measures rather
than mixing acoustic dB values with normalised behavioural scores.
Lower-is-better measures such as fatigue and listening effort are inverted
so that a larger radar value consistently represents a more favourable
profile.
"""
m = summary_metrics(df)
categories = [
"Use",
"Perceived benefit",
"Comfort",
"Listening success",
"Social participation",
"Acceptance",
"Engagement",
"Low fatigue",
"Low effort",
"Wearability",
]
values = [
m["use"],
m["benefit"],
m["comfort"],
m["success"],
m["social"],
m["acceptance"],
m["engagement"],
1.0 - m["fatigue"],
1.0 - m["listening_effort"],
m["wearability"],
]
# Close the polygon.
theta = categories + [categories[0]]
r = values + [values[0]]
fig = go.Figure()
fig.add_trace(
go.Scatterpolar(
r=r,
theta=theta,
fill="toself",
name=str(df["technology"].iloc[0]),
hovertemplate="%{theta}: %{r:.0%}
A synthetic-world laboratory for exploring how hearing technology, everyday environments and individual characteristics interact to shape technology use and behavioural experience.