Skip to content

Import college counseling data pipelines from futures-v2 - #23

Closed
amogh-gawaskar wants to merge 1 commit into
mainfrom
import-futures-v2
Closed

Import college counseling data pipelines from futures-v2#23
amogh-gawaskar wants to merge 1 commit into
mainfrom
import-futures-v2

Conversation

@amogh-gawaskar

Copy link
Copy Markdown

Summary

  • Imports four college counseling dataset pipelines from avantifellows/futures-v2: josaa, mcc, state_cet, state_medical
  • Adds COLLEGE_SOURCES_HANDOFF.md — high-level overview of all four sources
  • Each dataset follows the existing repo conventions: schemas/, scrape/scripts/, scripts/ (build, load_bq, sources, upload_to_gcs)

Datasets included

Folder Description
josaa/ JoSAA IIT/NIT/IIIT cutoff data, 2016–present
mcc/ MCC NEET-UG / AYUSH closing ranks (MBBS + BDS)
state_cet/ State engineering CET closing ranks (MH, KA, AP, TG, TN, WB, etc.)
state_medical/ State NEET-UG closing ranks across 30+ states

Test plan

  • Verify folder structure matches existing repo conventions (nirf/, plfs/, udise/)
  • Confirm schema YAMLs are present for each dataset
  • Spot-check scripts compile without errors

🤖 Generated with Claude Code

Adds four dataset pipelines: josaa, mcc, state_cet, and state_medical,
along with the COLLEGE_SOURCES_HANDOFF.md overview document.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines +151 to +152
r"|MIO?|MIH?"
r")$"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why changed: the trailing [HOS] was mandatory, but long codes like

# DEFRSEBC(S) / PWDROBC(S) / PWDRSEBC(S) wrap their trailing letter onto
# the next header line when the column is too narrow. Confirmed via
# direct PDF text dump: DEFRSEBC (137x), PWDROBC (62x), PWDRSEBC (11x)
# appear on header lines WITHOUT the trailing letter across the
# engineering CAP1-4 PDFs. Since the match failed, these columns were
# never registered, and every rank value under them was silently
# dropped. Made [HOS] optional (matches normalise_category()/_decode_body(),
# which already ignore the suffix for DEF/PWD-prefixed categories, so
# this doesn't change how they get bucketed — it just stops discarding
# them at the column-recognition step).
r"|DEF(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]?"
r"|PWD(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]?"   # Why changed: the trailing [HOS] was mandatory, but long codes like
# DEFRSEBC(S) / PWDROBC(S) / PWDRSEBC(S) wrap their trailing letter onto
# the next header line when the column is too narrow. Confirmed via
# direct PDF text dump: DEFRSEBC (137x), PWDROBC (62x), PWDRSEBC (11x)
# appear on header lines WITHOUT the trailing letter across the
# engineering CAP1-4 PDFs. Since the match failed, these columns were
# never registered, and every rank value under them was silently
# dropped. Made [HOS] optional (matches normalise_category()/_decode_body(),
# which already ignore the suffix for DEF/PWD-prefixed categories, so
# this doesn't change how they get bucketed — it just stops discarding
# them at the column-recognition step).

Comment on lines +148 to +149
r"|DEF(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]"
r"|PWD(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r"|DEF(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]"
r"|PWD(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]"
r"|DEF(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]?"
r"|PWD(?:R)?(?:OPEN|OBC|SC|ST|VJ|NT[1-3]|SEBC|SBC)[HOS]?"

Comment on lines +268 to +269
if "(" in ln and ")" in ln:
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why changed: pdftotext -layout sometimes splits one real Stage row
across two output lines when a value wraps, leaving a lone
percentage stranded on the same line as other real rank numbers.
The blanket rule discarded the WHOLE row (real numbers included) —
confirmed 122 rows lost in engineering CAP1-4, 4 in pharmacy.
Fix: mask out only the parenthesised percentage spans (keeping
column offsets intact for _match_column), and only skip the line
if nothing but whitespace/parens is left after masking.

Comment on lines +268 to +269
if "(" in ln and ")" in ln:
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if "(" in ln and ")" in ln:
continue
ln = re.sub(r"\([\d.]+\)", lambda mm: " " * len(mm.group()), ln)
if not re.search(r"\d{2,7}", ln):
continue

Comment on lines +328 to +336
ALLOT_PATTERN_FLAT = re.compile(
r"^\s*(\d{1,5})\s+"
r"(\d{1,7})\s*\(([\d.]+)\)\s+"
r"(\d{10}[A-Z]?)\s+"
r"(\d{5})\s*-\s*(.+?)\s{2,}"
r"(.+?)\s{2,}"
r"(MH to AI|AI to AI|MI to AI)\s+"
r"([A-Z]{1,12})\s*$"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#Why changed: the allotment-type + seat-type tail was mandatory, but many
real rows end right after the exam name (e.g. "...Mechanical Engineering
JEE") with no "MH to AI"/"AI to AI"/etc. text anywhere on the line at all
— confirmed directly against the source PDF, not a wrapping issue. Making
the tail optional recovered rows that were previously dropped whole
(measured: strict match rate went from ~33% to a much higher share of
loose matches across CAP1-4). allotment_type/seat_type are None on rows
where the PDF genuinely doesn't carry that tail — course_name may swallow
the exam name in that case, matching this parser's existing behaviour.

Comment on lines +333 to +335
r"(.+?)\s{2,}"
r"(MH to AI|AI to AI|MI to AI)\s+"
r"([A-Z]{1,12})\s*$"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r"(.+?)\s{2,}"
r"(MH to AI|AI to AI|MI to AI)\s+"
r"([A-Z]{1,12})\s*$"
r"(.+?)"
r"(?:\s{2,}(MH to AI|AI to AI|MI to AI)\s+([A-Z]{1,12}))?"
r"\s*$"

Comment on lines +341 to +348
ALLOT_PATTERN_RANKROW = re.compile(
r"^\s*(\d{1,5})\s+"
r"(\d{1,7})\s*\(([\d.]+)\)\s+"
r"(\d{10}[A-Z]?)\s+.*?"
r"(JEE|NEET|MHT-?CET|CET)\s+"
r"(MH to AI|AI to AI|MI to AI)\s+"
r"([A-Z]{1,12})\s*$"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ALLOT_PATTERN_RANKROW = re.compile(
r"^\s*(\d{1,5})\s+"
r"(\d{1,7})\s*\(([\d.]+)\)\s+"
r"(\d{10}[A-Z]?)\s+.*?"
r"(JEE|NEET|MHT-?CET|CET)\s+"
r"(MH to AI|AI to AI|MI to AI)\s+"
r"([A-Z]{1,12})\s*$"
)
# Why changed: same issue as ALLOT_PATTERN_FLAT — the allotment-type +
# seat-type tail was mandatory, but some rows genuinely end right after the
# exam name with no tail text at all. Made it optional so those rows are
# kept (with allotment_type/seat_type as None) instead of dropped whole.
ALLOT_PATTERN_RANKROW = re.compile(
r"^\s*(\d{1,5})\s+"
r"(\d{1,7})\s*\(([\d.]+)\)\s+"
r"(\d{10}[A-Z]?)\s+.*?"
r"(JEE|NEET|MHT-?CET|CET)"
r"(?:\s+(MH to AI|AI to AI|MI to AI)\s+([A-Z]{1,12}))?"
r"\s*$"
)

Comment on lines +527 to +541
agg = (df.groupby(
["college_code", "college_name", "branch_code", "branch_name",
"status", "quota_section", "category_raw"], dropna=False)
.agg(closing_rank=("rank", "max"),
opening_rank=("rank", "min"),
num_rank_observations=("rank", "count"))
.reset_index())
last = (df.sort_values("rank", ascending=False)
.drop_duplicates(["college_code", "branch_code", "quota_section", "category_raw"])
[["college_code", "branch_code", "quota_section", "category_raw",
"round", "stage"]]
.rename(columns={"round": "last_round_with_max",
"stage": "last_stage_with_max"}))
agg = agg.merge(last, on=["college_code", "branch_code", "quota_section", "category_raw"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
agg = (df.groupby(
["college_code", "college_name", "branch_code", "branch_name",
"status", "quota_section", "category_raw"], dropna=False)
.agg(closing_rank=("rank", "max"),
opening_rank=("rank", "min"),
num_rank_observations=("rank", "count"))
.reset_index())
last = (df.sort_values("rank", ascending=False)
.drop_duplicates(["college_code", "branch_code", "quota_section", "category_raw"])
[["college_code", "branch_code", "quota_section", "category_raw",
"round", "stage"]]
.rename(columns={"round": "last_round_with_max",
"stage": "last_stage_with_max"}))
agg = agg.merge(last, on=["college_code", "branch_code", "quota_section", "category_raw"])
# Why changed: pdftotext -layout sometimes merges a stray boilerplate
# line (e.g. a recurring page-header line) onto the SAME output line as
# a college-code header, so college_name/status pick up trailing
# garbage text on some pages but not others for the SAME real
# college_code. Grouping by college_name/branch_name/status (literal
# strings) as part of the key then silently split one real institute
# into multiple rows, each aggregated over only part of its true data —
# producing duplicate rows with DIFFERENT, both-wrong (undercounted)
# closing ranks. Confirmed: 143 conflicting duplicate rows in
# engineering's final output alone.
#
# First attempt just dropped college_name/branch_name/status from the
# groupby key entirely and canonicalized status to one value per
# (college_code, branch_code) — but status is NOT always constant even
# within one branch: some institutes (e.g. Bombay College of Pharmacy,
# 03016) genuinely run BOTH Government-Aided AND Un-Aided seats under
# the same branch code, confirmed directly in the source PDF (both
# statuses appear within the same round). Collapsing status there
# silently merged two real, distinctly-funded seat pools into one and
# dropped 3 genuinely-govt colleges from the govt-scope count.
#
# Fix: classify college_type from the RAW per-row status BEFORE
# aggregating, and include college_type (not raw status text) in the
# groupby key. This restores the original correct behaviour for genuine
# dual-status branches (they still get 2 separate rows, one per type —
# confirmed Bombay College of Pharmacy's two status strings both already
# map to their real types), while still fixing the actual corruption bug:
# corrupted college_name/status text (e.g. Sardar Patel College's
# boilerplate-contaminated row) maps to the SAME college_type as its
# clean counterpart, so those still correctly merge into one row.
df["college_type"] = df["status"].apply(classify_college_type)
agg = (df.groupby(
["college_code", "branch_code", "quota_section", "category_raw",
"college_type"], dropna=False)
.agg(closing_rank=("rank", "max"),
opening_rank=("rank", "min"),
num_rank_observations=("rank", "count"))
.reset_index())
def _canonical(frame, keys, value_col):
return (frame.assign(_len=frame[value_col].fillna("").str.len())
.sort_values("_len")
.drop_duplicates(keys)[keys + [value_col]])
agg = agg.merge(_canonical(df, ["college_code"], "college_name"),
on="college_code", how="left")
agg = agg.merge(_canonical(df, ["college_code", "branch_code"], "branch_name"),
on=["college_code", "branch_code"], how="left")
# status text is canonicalized WITHIN each (college_code, branch_code,
# college_type) group — safe because every row in that group already
# shares the same real type; this only cleans up incidental text noise
# (e.g. a trailing "Home University : ..." clause present on some pages
# and not others for the same status).
agg = agg.merge(_canonical(df, ["college_code", "branch_code", "college_type"], "status"),
on=["college_code", "branch_code", "college_type"], how="left")
last = (df.sort_values("rank", ascending=False)
.drop_duplicates(["college_code", "branch_code", "quota_section",
"category_raw", "college_type"])
[["college_code", "branch_code", "quota_section", "category_raw",
"college_type", "round", "stage"]]
.rename(columns={"round": "last_round_with_max",
"stage": "last_stage_with_max"}))
agg = agg.merge(last, on=["college_code", "branch_code", "quota_section",
"category_raw", "college_type"])

agg["quota"] = agg["quota_section"]
agg["rank_basis"] = f"{cfg['cet_name']} State Merit Rank"
agg["source_url"] = cfg["source_url"]
agg["college_type"] = agg["status"].apply(classify_college_type)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
agg["college_type"] = agg["status"].apply(classify_college_type)
# college_type is already a column (computed pre-aggregation and used as
# a groupby key above) — no need to recompute it from the canonicalized
# status text.

Comment on lines +67 to +68
r"[*@~^&]\s*" # color marker (^/~/*/@ etc.)
r"([A-Z]{4,12})\s*$" # SeatType (e.g., LOPENH, GOBCH)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r"[*@~^&]\s*" # color marker (^/~/*/@ etc.)
r"([A-Z]{4,12})\s*$" # SeatType (e.g., LOPENH, GOBCH)
# Why changed: the marker was mandatory, but Round 1 (and a small % of
# every other round) legitimately has no marker at all — it denotes
# "carried forward from a previous round," which doesn't exist yet in
# Round 1. That alone silently dropped ~1,811 rows (~99% of Round 1).
# Separately, {4,12} rejected real short SeatType codes like "AI"
# (All India, 2 letters) and "EWS"/"SC"/"ST" (2-3 letters), dropping
# another ~1,060 rows even when the marker WAS present.
r"[*@~^&]?\s*" # color marker (^/~/*/@ etc.) — now optional
r"([A-Z]{2,12})\s*$" ```

suryabulusu added a commit that referenced this pull request Aug 18, 2026
* tnea: import the TN scraper with the govt-college classification fix

Seeds the tnea/ source folder with the TNEA scraper from futures-v2, including
the console-script portal extractor and the code-based government-college
classification. Schemas, loaders and the tnea_fact_cutoffs table follow when
this source ships end to end, per the one-source-at-a-time migration (PR #23).

Co-authored-by: sakshi jadhave <sakshi.s.jadhav1755@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@claude.com>

* tnea: add the two constituent UCEs the 2021 DOTE PDF predates

Review of the imported classifier against the real 2025 portal data (423
colleges): all 36 listed codes present and correctly classified, zero false
positives -- but a sweep for unclassified govt-looking names found two:

  5017  University College of Engineering, Ramanathapuram
  5022  University College of Engineering, Dindigul

Both are Anna University constituent colleges, the same family as the 14
already in CONSTITUENT_COLLEGE_CODES; they are simply newer than the Dec-2021
"List of Colleges (Code Number wise)" PDF the sets were transcribed from.
Without them the TN govt scope silently drops 2 of its ~35 colleges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@claude.com>

---------

Co-authored-by: sakshi jadhave <sakshi.s.jadhav1755@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@claude.com>
@suryabulusu

Copy link
Copy Markdown
Contributor

WBJEE: done (the first source to run the full ONBOARDING.md checklist end to end).

  • Pipeline wbjee: 2021-2026 engineering cutoffs, including the live 2026 cycle #87: fetched WBJEEB's ORCR directly for 2021-2026 engineering + 2026 pharmacy - including the live 2026 cycle (Rounds 1-3), our first current-cycle data. The old scraper's browser-saved-HTML workaround is explained and retired: the enc token's + must be passed literally, not percent-encoded.
  • BQ: wbjee_fact_cutoffs, 21,936 rows across six years. Schema doc + registry row in data-assistant (cfdd43c).
  • Predictor: WBJEE: new exam with the live 2026 cycle's closing ranks college-predictor#322 - WBJEE exam with 2026 actuals (browser audit caught a label!=value bug before ship).
  • Open data: 8 files published (six ORCR years verbatim + extracted CSV), live on /datasets.
  • futures-v2 thinned: state_cet/scrape/scripts/state_WB.py marked superseded (819c5cc); it remains only as an input to the consolidated 5-cat product.

Remaining in the state_cet queue: AP (needs a data hunt - scraper used a 2022 proxy), KEAM, Bihar, Kerala, Odisha.

@suryabulusu

Copy link
Copy Markdown
Contributor

KEAM: done (second full ONBOARDING.md run; the checklist held, including its two newest steps).

  • Pipeline keam: Kerala engineering last ranks 2025-2026, incl. the live 2026 cycle #88: CEE Kerala's last-rank PDFs, 2025 + the live 2026 cycle (engineering Trial/P1/P2 as of 21 Aug; arch, pharm, and 2025 medical archived). CEE keeps only the last two cycles online - 2023/2024 already 403 - so the raw archive is the durable copy.
  • BQ: keam_fact_cutoffs, 19,610 rows. One real parser fix over futures-v2's state_KL.py: course headers are not repeated when tables spill across page breaks - the per-page course reset orphaned rows and produced 22 duplicate buckets, caught by the zero-dupes assert.
  • data-assistant: schema + CLAUDE.md via regen_claude_md.py, both CI checks green on the push (a426fb8).
  • Predictor: KEAM: new exam with the live 2026 cycle's last ranks college-predictor#323 (CI green) - KEAM exam with 2026 actuals; Kerala's community codes decoded in a helper note; Trial phase excluded from the app.
  • Open data: 19 files live on /datasets (year + stream + phase in every chip title).
  • futures-v2 thinned: state_KL.py marked superseded.

Remaining in the state_cet queue: AP (data hunt needed), Bihar, Odisha.

@suryabulusu

Copy link
Copy Markdown
Contributor

AP EAPCET: done (fourth checklist run; the data hunt is resolved).

  • The hunt: APSCHE republished the 2025 consolidated last-rank PDF on the live 2026-27 CAP portal (cap.apcfss.in) - exactly where state_AP.py's notes predicted. Archived on sight: the 2024 file already 404s, the old aptonline host refuses connections, and the CAP server soft-200s bogus paths (refetches must check content-type). The 2022-proxy era is over.
  • Pipeline apeapcet: AP engineering last ranks - 2025 actuals replace the 2022 proxy #89: apeapcet_fact_cutoffs, 29,848 rows - college x branch x local-area x category x gender, 274 colleges. 2025 brings SC sub-classification (SC-I/II/III); private universities publish separate AU/SVU region rows (local_area is grain, caught by the zero-dupes assert); college_type from APSCHE's own type column.
  • data-assistant: schema + regenerated CLAUDE.md, both checks green (43e9d74).
  • Predictor: AP EAPCET: new exam with the 2025 consolidated last ranks college-predictor#324 (CI green) - AP EAPCET exam, all dropdowns decoded, branch codes verbatim, Boys/Girls pools labelled from the source's own footnote.
  • Open data: raw PDF + extracted CSV live on /datasets.
  • futures-v2 thinned: state_AP.py marked superseded.
  • Refresh drill: probe EAPCET2026LASTRANKDETAILS.pdf when the live 2026 counselling concludes - documented in apeapcet/scripts/sources.py.

Remaining in the state_cet queue: Bihar, Odisha.

@suryabulusu

Copy link
Copy Markdown
Contributor

OJEE (Odisha): done - and with it, every scraper in the state_cet queue is migrated. Five full ONBOARDING.md runs: WBJEE, KEAM, AP EAPCET, OJEE (plus TNEA/KCET/GUJCET before the checklist existed).

  • The hunt: the 2025 B.Tech OR-CR this queue marked as login-gated is now public - OJEE published it in May 2026 on ojee.nic.in/opening-closing-rank/ as reference for the live 2026 cycle (the AP pattern again). Archived; the 2024 proxy is retired.
  • Pipeline ojee: Odisha B.Tech last ranks - 2025 actuals replace the 2024 proxy #90: ojee_fact_cutoffs, 1,543 rows. The headline gotcha: Odisha B.Tech admits on JEE Main ranks - closings run into the lakhs - and rank_family tags the document's three scales (btech-jeemain / barch-bplan / film). The PDF physically overprints six institutes' names onto programme text; recovery is deterministic and asserted (README documents it).
  • data-assistant: schema + regenerated CLAUDE.md, both checks green (1ab1940).
  • Predictor: OJEE: Odisha B.Tech exam on JEE Main ranks (2025) college-predictor#325 (CI green) - input is 'Enter JEE (Main) Rank', btech family only, TFW as its own pool.
  • Open data: raw PDF + extracted CSV live on /datasets.
  • futures-v2 thinned: state_OD.py marked superseded - the last one.

state_cet queue: EMPTY. Remaining on this PR: the josaa/ waystation (already mirrored here) and the standing refresh drills (WBJEE mop-up, KEAM P3/medical, AP 2026, OJEE 2026).

@suryabulusu

Copy link
Copy Markdown
Contributor

Closing this PR: the migration it tracked is complete.

Every source in the queue now lives in external_data_sources with the full chain - raw archive in GCS, clean parquet, BigQuery fact, data-assistant schema, college-predictor exam, and open-data publication:

Source Fact table Data Predictor
TNEA tnea_fact_cutoffs 2025 #313
KCET kcet refresh 2025 eng #319
GUJCET gujcet 2025 (earlier)
NEET (scrape + state_medical + mcc + matrix) neet_fact_cutoffs + matrix 2025/2026 NEETUG
WBJEE wbjee_fact_cutoffs 2021-2026 incl. live cycle #322
KEAM keam_fact_cutoffs 2025 + live 2026 #323
AP EAPCET apeapcet_fact_cutoffs 2025 (2022-proxy retired) #324
OJEE ojee_fact_cutoffs 2025 (2024-proxy retired) #325

futures-v2 is thinned to pointer notes; the process that did this is codified in ONBOARDING.md. Standing refresh drills (documented in each source's sources.py): WBJEE mop-up round, KEAM engineering P3 + Kerala 2026 medical, AP EAPCET 2026 consolidated, OJEE 2026 OR-CR, and the NIC-portal lead for OJEE's non-B.Tech courses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants