pri_phase_check / src /streamlit_app.py
hongaik's picture
Update src/streamlit_app.py
35bff9d verified
import streamlit as st
import requests
import math
# --- Logic Functions ---
def calculate_distance(postal1, postal2, onemap_token=None):
"""
Calculates the Euclidean distance in km between two postal codes using OneMap API.
Returns:
tuple: (distance_km, error_message)
- distance_km (float): Distance in km, or None if error.
- error_message (str): None if success, else specific error description.
"""
def get_coordinates(postal):
try:
# Basic format validation (Singapore postal codes are 6 digits)
if not str(postal).isdigit() or len(str(postal)) != 6:
return None, f"Invalid Postal Format: {postal}"
url = f"https://www.onemap.gov.sg/api/common/elastic/search?searchVal={postal}&returnGeom=Y&getAddrDetails=N&pageNum=1"
headers = {}
if onemap_token:
headers["Authorization"] = onemap_token
response = requests.get(url, headers=headers, timeout=5)
if response.status_code != 200:
return None, f"OneMap API Error: Status {response.status_code}"
data = response.json()
if data['found'] > 0:
result = data['results'][0]
return (float(result['X']), float(result['Y'])), None
return None, f"Invalid Postal Code: {postal}"
except requests.exceptions.RequestException as e:
return None, f"OneMap API Connection Error: {str(e)}"
except Exception as e:
return None, f"Unexpected Error: {str(e)}"
coord1, err1 = get_coordinates(postal1)
if err1:
return None, err1
coord2, err2 = get_coordinates(postal2)
if err2:
return None, err2
# Calculate Euclidean distance on SVY21 plane (coordinates are in meters)
distance_meters = math.sqrt((coord1[0] - coord2[0])**2 + (coord1[1] - coord2[1])**2)
return distance_meters / 1000.0, None
def get_distance_category(distance_km):
if distance_km < 1.0:
return 1 # < 1km
elif distance_km < 2.0:
return 2 # 1-2km
else:
return 3 # > 2km
def get_registration_phase(citizenship, user_postal, school_postal,
has_sibling, is_alumni, is_staff, is_mk,
is_volunteer, is_church_clan, is_community_leader,
is_pr=False, onemap_token=None):
"""
Determines the earliest eligible P1 registration phase and priority code.
Args:
citizenship (str): "SCPR" or "International".
user_postal (str): 6-digit postal code of user.
school_postal (str): 6-digit postal code of school.
... boolean args ...
is_pr (bool): True if Permanent Resident, False if Singapore Citizen. Only relevant if citizenship="SCPR".
Returns:
str: The priority code (e.g. "P1", "P2A-1") OR "ERROR: <Reason>"
"""
# --- Logic ---
if citizenship == "International":
return "P3"
# Phase 1: Guaranteed (Conceptually P1 does not balloting by distance usually, but let's assign P1)
if has_sibling:
return "P1"
# Calculate Distance - Catching specific errors from the helper
dist, error_msg = calculate_distance(user_postal, school_postal, onemap_token)
if error_msg:
return f"ERROR: {error_msg}"
# Helper for priority suffix
def get_suffix(d_km, is_pr_status):
cat = get_distance_category(d_km)
# SC: 1, 2, 3
# PR: 4, 5, 6
if is_pr_status:
return f"-{cat + 3}"
else:
return f"-{cat}"
suffix = get_suffix(dist, is_pr)
# Phase 2A
if is_alumni or is_staff or is_mk:
return f"P2A{suffix}"
# Phase 2B
# Check Community Leader specific 2km rule
valid_community_leader = is_community_leader and (dist < 2.0)
if is_volunteer or is_church_clan or valid_community_leader:
return f"P2B{suffix}"
# Phase 2C
return f"P2C{suffix}"
# --- Streamlit App ---
def main():
st.set_page_config(page_title="P1 Registration Eligibility Checker", page_icon="cP", layout="wide")
st.title("P1 Registration Eligibility Checker")
st.markdown("""
Check which phase your child is eligible for in the Primary 1 (P1) Registration Exercise.
*Based on 2025 rules for 2026 intake.*
""")
st.divider()
# 1. Citizenship
st.subheader("1. Citizenship")
citizenship_radio = st.radio(
"What is the child's citizenship status?",
["Singapore Citizen", "Permanent Resident", "International Student"],
horizontal=True
)
is_pr = False
citizenship_code = "SCPR"
if citizenship_radio == "International Student":
citizenship_code = "International"
st.warning("International Students must register in **Phase 3**.")
st.info("Important: You must submit an 'Indication of Interest' form online (typically in May/June) before you can register in Phase 3 (typically in October).")
return # Stop execution for International students as other questions are irrelevant
elif citizenship_radio == "Permanent Resident":
is_pr = True
citizenship_code = "SCPR"
else:
# Singapore Citizen
is_pr = False
citizenship_code = "SCPR"
st.divider()
# 2. Postal Codes
st.subheader("2. Location (Postal Codes)")
st.info("Distance is calculated using the OneMap API.")
col_postal1, col_postal2 = st.columns(2)
with col_postal1:
user_postal = st.text_input("Home Postal Code", max_chars=6, help="Enter your 6-digit home postal code.")
with col_postal2:
school_postal = st.text_input("School Postal Code", max_chars=6, help="Enter the 6-digit postal code of the school.")
with st.expander("Advanced: OneMap API Token (Optional)"):
onemap_token = st.text_input("API Token", type="password", help="If public API limits are hit, enter a valid OneMap token here.")
if not onemap_token:
onemap_token = None
st.divider()
# 3. School Connections
st.subheader("3. Eligibility Criteria")
st.write("Tick all that apply to see your eligible phase.")
col1, col2 = st.columns(2)
with col1:
st.markdown("**Phase 1**")
has_sibling = st.checkbox(
"Child has a sibling currently studying in the school of choice",
help="For a child who has a sibling currently studying in the primary school."
)
st.markdown("**Phase 2A**")
is_alumni = st.checkbox(
"Parent/Sibling is an alumni",
help="For a child whose parent or sibling is a former student of the primary school, including those who have joined the alumni association."
)
is_staff = st.checkbox(
"Parent is a staff member or SAC/SMC member",
help="For a child whose parent is a member of the School Advisory or Management Committee, or a staff member of the primary school."
)
is_mk = st.checkbox(
"Child attends MOE Kindergarten at the school",
help="For a child from the MOE Kindergarten under the purview of and located within the primary school."
)
with col2:
st.markdown("**Phase 2B**")
is_volunteer = st.checkbox(
"Parent is a school volunteer",
help="For a child whose parent has joined the primary school as a parent volunteer not later than 1 July of the year before P1 registration and has given at least 40 hours of voluntary service to the school by 30 June of the registration year."
)
is_church_clan = st.checkbox(
"Parent is a member of endorsed church/clan",
help="For a child whose parent is a member endorsed by the church or clan directly connected with the primary school."
)
is_community_leader = st.checkbox(
"Parent is an active community leader",
help="For a child whose parent is endorsed as an active community leader. *Note: Must be within 2km of the school to qualify for Phase 2B.*"
)
st.divider()
# Calculate Button
if st.button("Check Eligibility", type="primary", use_container_width=True):
if not user_postal or len(user_postal) != 6 or not user_postal.isdigit():
st.error("Please enter a valid 6-digit Home Postal Code.")
elif not school_postal or len(school_postal) != 6 or not school_postal.isdigit():
st.error("Please enter a valid 6-digit School Postal Code.")
else:
with st.spinner("Calculating distance and eligibility..."):
result = get_registration_phase(
citizenship=citizenship_code,
user_postal=user_postal,
school_postal=school_postal,
has_sibling=has_sibling,
is_alumni=is_alumni,
is_staff=is_staff,
is_mk=is_mk,
is_volunteer=is_volunteer,
is_church_clan=is_church_clan,
is_community_leader=is_community_leader,
is_pr=is_pr,
onemap_token=onemap_token
)
if result.startswith("ERROR:"):
st.error(result)
else:
st.success(f"**Your eligible Phase & Priority Code: {result}**")
# Explain code
phase = result.split("-")[0]
priority = result.split("-")[1] if "-" in result else ""
explanation = ""
if phase == "P1":
explanation = "Phase 1: Guaranteed admission."
elif "P2A" in phase:
explanation = "Phase 2A: For alumni, staff, and MOE Kindergarten children."
elif "P2B" in phase:
explanation = "Phase 2B: For volunteers, church/clan members, and community leaders."
elif "P2C" in phase:
explanation = "Phase 2C: Open to all eligible children."
if priority:
cat_map = {
"1": "Singapore Citizen < 1km",
"2": "Singapore Citizen 1-2km",
"3": "Singapore Citizen > 2km",
"4": "Permanent Resident < 1km",
"5": "Permanent Resident 1-2km",
"6": "Permanent Resident > 2km"
}
explanation += f"\n\n**Priority Category**: {cat_map.get(priority, 'Unknown')}"
st.info(explanation)
st.caption("Note: This tool is for reference only. Official eligibility is determined by MOE. Distance is calculated via OneMap API (Euclidean/Straight-line).")
if __name__ == "__main__":
main()