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: " """ # --- 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()