Open Materials Challenge season 1 - solid-state electrolyte
Browse files- Dockerfile +12 -0
- README.md +7 -4
- app.py +327 -0
- data/anchor_scores.json +226 -0
- data/reference_dist.json +1 -0
- data/weights.json +8 -0
- gates.py +90 -0
- index.html +424 -0
- requirements.txt +4 -0
- seasons.py +75 -0
- store.py +187 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HF Spaces runs the container as a non-root user; write only under paths we own.
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
COPY . .
|
| 8 |
+
RUN mkdir -p /app/data && chown -R user:user /app
|
| 9 |
+
USER user
|
| 10 |
+
ENV OMC_LEDGER=/app/data/ledger.jsonl
|
| 11 |
+
EXPOSE 7860
|
| 12 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
| 1 |
---
|
| 2 |
title: Open Materials Challenge
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Open Materials Challenge
|
| 3 |
+
emoji: 🔋
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Open Materials Challenge
|
| 12 |
+
|
| 13 |
+
시즌 1 — 전고체 배터리 전해질. **비공개 준비 중.**
|
app.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Open Materials Challenge — 접수와 순위표.
|
| 3 |
+
|
| 4 |
+
**이 서비스가 하지 않는 일**: 채점. 스페이스에는 Materials Project 자료도 GPU 도 없다.
|
| 5 |
+
제출을 받아 원장(비공개 데이터셋)에 적고, 워커가 채점해 넣은 결과를 보여줄 뿐이다.
|
| 6 |
+
여기서 물성을 판정하는 척하면 참가자는 근거 없는 반려를 받게 된다.
|
| 7 |
+
|
| 8 |
+
순위표는 TTL 당 한 번만 만들어 미리 압축해 두고, 캐시는 단일 갱신자로 돌린다.
|
| 9 |
+
첫 페이지는 async 로 서빙하고, 표는 500 행씩 나눠 그린다.
|
| 10 |
+
"""
|
| 11 |
+
import base64
|
| 12 |
+
import gzip
|
| 13 |
+
import hashlib
|
| 14 |
+
import hmac
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import secrets
|
| 18 |
+
import time
|
| 19 |
+
import urllib.parse
|
| 20 |
+
import urllib.request
|
| 21 |
+
import uuid
|
| 22 |
+
|
| 23 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 24 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 25 |
+
from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse,
|
| 26 |
+
Response)
|
| 27 |
+
from pydantic import BaseModel
|
| 28 |
+
|
| 29 |
+
import gates
|
| 30 |
+
import seasons
|
| 31 |
+
import store
|
| 32 |
+
|
| 33 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 34 |
+
DATA = os.path.join(HERE, "data")
|
| 35 |
+
# 페이지 폴링 주기(20초)보다 크게 잡는다.
|
| 36 |
+
CACHE = store.Cached(ttl=int(os.environ.get("OMC_CACHE_TTL", "60")))
|
| 37 |
+
SALT = os.environ.get("OMC_SALT", "omc-season1")
|
| 38 |
+
|
| 39 |
+
OAUTH_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
| 40 |
+
OAUTH_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 41 |
+
OAUTH_ISS = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 42 |
+
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| 43 |
+
COOKIE = "omc_session"
|
| 44 |
+
IN_FRAME = bool(SPACE_HOST)
|
| 45 |
+
COOKIE_KW = ({"samesite": "none", "secure": True} if IN_FRAME
|
| 46 |
+
else {"samesite": "lax", "secure": False})
|
| 47 |
+
SESSION_KEY = os.environ.get("OMC_SESSION_KEY") or secrets.token_hex(16)
|
| 48 |
+
|
| 49 |
+
DAILY_CAP = int(os.environ.get("OMC_DAILY_CAP", "30"))
|
| 50 |
+
CAP_TZ_OFFSET = 9 * 3600 # 참가자가 가정할 하루 경계 (KST)
|
| 51 |
+
|
| 52 |
+
app = FastAPI(title="Open Materials Challenge")
|
| 53 |
+
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class Submission(BaseModel):
|
| 57 |
+
formula: str # 조성. 예: Li3YCl6, LiZr2(PO4)3
|
| 58 |
+
display_name: str # 순위표에 표시될 이름
|
| 59 |
+
model_name: str = "" # 어떤 모델이 제안했는지. 자유 기재
|
| 60 |
+
rationale: str = "" # 설계 근거 (선택)
|
| 61 |
+
structure: str = "" # CIF (선택) - 내면 채점이 빠르고 정확해진다
|
| 62 |
+
season: int = 0
|
| 63 |
+
# 조성을 공개할지는 제출자가 정한다. 되돌릴 수 없는 쪽이 기본값이어야 한다 -
|
| 64 |
+
# 한 번 공개된 조성은 되감을 수 없고, 공개는 특허성을 깎는다.
|
| 65 |
+
visibility: str = "private"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------- 원장 접근
|
| 69 |
+
def _submissions(s):
|
| 70 |
+
return CACHE.get("ids:%d" % s["number"],
|
| 71 |
+
lambda: store.listdir(seasons.path(s, "submissions")))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _board(s):
|
| 75 |
+
return CACHE.get("board:%d" % s["number"],
|
| 76 |
+
lambda: store.read(seasons.path(s, "leaderboard.json"),
|
| 77 |
+
default={}) or {})
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def public_id(key):
|
| 81 |
+
return "OMC-" + hashlib.sha256((SALT + key).encode()).hexdigest()[:10].upper()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def mask(rec):
|
| 85 |
+
"""비공개 제출에서 조성을 가린다. 원소 종류까지는 보여준다 - 그것만으로는
|
| 86 |
+
조성을 되돌릴 수 없고, 어떤 화학계가 상위인지는 대회의 공개 지식이어야 한다."""
|
| 87 |
+
out = dict(rec)
|
| 88 |
+
if rec.get("visibility") != "public":
|
| 89 |
+
comp = rec.get("composition") or {}
|
| 90 |
+
out["formula"] = "·".join(sorted(comp)) if comp else "비공개"
|
| 91 |
+
out["masked"] = True
|
| 92 |
+
out.pop("structure", None)
|
| 93 |
+
return out
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------- 세션
|
| 97 |
+
def _sign(payload):
|
| 98 |
+
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
| 99 |
+
sig = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
|
| 100 |
+
return raw + "." + sig
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _unsign(token):
|
| 104 |
+
try:
|
| 105 |
+
raw, sig = (token or "").rsplit(".", 1)
|
| 106 |
+
except ValueError:
|
| 107 |
+
return None
|
| 108 |
+
good = hmac.new(SESSION_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()[:32]
|
| 109 |
+
if not hmac.compare_digest(sig, good):
|
| 110 |
+
return None
|
| 111 |
+
pad = "=" * (-len(raw) % 4)
|
| 112 |
+
try:
|
| 113 |
+
return json.loads(base64.urlsafe_b64decode(raw + pad))
|
| 114 |
+
except Exception:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def current_user(request: Request):
|
| 119 |
+
return _unsign(request.cookies.get(COOKIE))
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _redirect_uri(request: Request):
|
| 123 |
+
if SPACE_HOST:
|
| 124 |
+
return "https://%s/auth/callback" % SPACE_HOST
|
| 125 |
+
return str(request.base_url).rstrip("/") + "/auth/callback"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------- 화면
|
| 129 |
+
@app.get("/")
|
| 130 |
+
async def index():
|
| 131 |
+
# async 인 이유: sync 라우트는 요청 스���드풀에서 돈다. 그 풀이 Hub I/O 로 막히면
|
| 132 |
+
# 정적 페이지까지 뒤에 줄을 서서 사이트가 죽은 것처럼 보인다.
|
| 133 |
+
return FileResponse(os.path.join(HERE, "index.html"))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@app.get("/api/me")
|
| 137 |
+
def me(request: Request):
|
| 138 |
+
u = current_user(request)
|
| 139 |
+
return {"signed_in": bool(u), "user": u,
|
| 140 |
+
"oauth_configured": bool(OAUTH_ID and OAUTH_SECRET)}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.get("/login")
|
| 144 |
+
def login(request: Request):
|
| 145 |
+
if not (OAUTH_ID and OAUTH_SECRET):
|
| 146 |
+
raise HTTPException(503, "OAuth 가 설정되지 않았습니다.")
|
| 147 |
+
state = secrets.token_urlsafe(16)
|
| 148 |
+
q = urllib.parse.urlencode({
|
| 149 |
+
"client_id": OAUTH_ID, "redirect_uri": _redirect_uri(request),
|
| 150 |
+
"response_type": "code", "scope": "openid profile", "state": state})
|
| 151 |
+
r = RedirectResponse("%s/oauth/authorize?%s" % (OAUTH_ISS, q))
|
| 152 |
+
r.set_cookie("omc_state", state, max_age=600, httponly=True, **COOKIE_KW)
|
| 153 |
+
return r
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@app.get("/auth/callback")
|
| 157 |
+
def callback(request: Request, code: str = "", state: str = ""):
|
| 158 |
+
if not code or state != request.cookies.get("omc_state"):
|
| 159 |
+
return RedirectResponse("/?auth=failed")
|
| 160 |
+
body = urllib.parse.urlencode({
|
| 161 |
+
"client_id": OAUTH_ID, "client_secret": OAUTH_SECRET,
|
| 162 |
+
"grant_type": "authorization_code", "code": code,
|
| 163 |
+
"redirect_uri": _redirect_uri(request)}).encode()
|
| 164 |
+
try:
|
| 165 |
+
req = urllib.request.Request("%s/oauth/token" % OAUTH_ISS, data=body)
|
| 166 |
+
with urllib.request.urlopen(req, timeout=30) as x:
|
| 167 |
+
tok = json.loads(x.read())
|
| 168 |
+
req = urllib.request.Request(
|
| 169 |
+
"%s/oauth/userinfo" % OAUTH_ISS,
|
| 170 |
+
headers={"Authorization": "Bearer " + tok["access_token"]})
|
| 171 |
+
with urllib.request.urlopen(req, timeout=30) as x:
|
| 172 |
+
info = json.loads(x.read())
|
| 173 |
+
except Exception:
|
| 174 |
+
return RedirectResponse("/?auth=failed")
|
| 175 |
+
u = {"sub": info.get("sub"), "name": info.get("preferred_username") or info.get("name"),
|
| 176 |
+
"picture": info.get("picture")}
|
| 177 |
+
r = RedirectResponse("/?auth=ok")
|
| 178 |
+
r.set_cookie(COOKIE, _sign(u), max_age=30 * 86400, httponly=True, **COOKIE_KW)
|
| 179 |
+
r.delete_cookie("omc_state")
|
| 180 |
+
return r
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.get("/logout")
|
| 184 |
+
def logout():
|
| 185 |
+
r = RedirectResponse("/")
|
| 186 |
+
r.delete_cookie(COOKIE)
|
| 187 |
+
return r
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.get("/api/seasons")
|
| 191 |
+
def season_list():
|
| 192 |
+
return {"seasons": seasons.all_public()}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.get("/api/season")
|
| 196 |
+
def season(request: Request):
|
| 197 |
+
return seasons.public(seasons.get(request.query_params.get("season")))
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------- 제출
|
| 201 |
+
@app.post("/api/submit")
|
| 202 |
+
def submit(s: Submission, request: Request):
|
| 203 |
+
season = seasons.get(s.season or None)
|
| 204 |
+
if not season.get("open"):
|
| 205 |
+
raise HTTPException(400, "이 시즌은 제출을 받지 않습니다.")
|
| 206 |
+
user = current_user(request)
|
| 207 |
+
if (OAUTH_ID and OAUTH_SECRET) and not user:
|
| 208 |
+
raise HTTPException(401, "제출하려면 Hugging Face 로그인이 필요합니다.")
|
| 209 |
+
|
| 210 |
+
g = seasons.gate(season)
|
| 211 |
+
ok, why, info = gates.check(s.formula, cif=s.structure or None,
|
| 212 |
+
require_elements=g.get("require_elements", ("Li",)),
|
| 213 |
+
max_atoms=g.get("max_atoms", 60))
|
| 214 |
+
if not ok:
|
| 215 |
+
raise HTTPException(400, why)
|
| 216 |
+
|
| 217 |
+
comp = info["composition"]
|
| 218 |
+
# 열쇠는 **기약 조성**으로 만든다. Li3YCl6 와 Li9Y3Cl18 은 같은 물질이다.
|
| 219 |
+
# 원본 조성으로 만들면 배수만 바꿔 같은 물질을 몇 번이고 올릴 수 있다.
|
| 220 |
+
red = info.get("reduced") or comp
|
| 221 |
+
key = "-".join("%s%g" % (k, red[k]) for k in sorted(red))
|
| 222 |
+
cid = public_id(key)
|
| 223 |
+
|
| 224 |
+
ids = _submissions(season)
|
| 225 |
+
if cid in ids:
|
| 226 |
+
raise HTTPException(409, "이미 제출된 조성입니다 (%s)." % cid)
|
| 227 |
+
|
| 228 |
+
if user:
|
| 229 |
+
today = int((time.time() + CAP_TZ_OFFSET) // 86400)
|
| 230 |
+
mine = CACHE.get("cap:%s:%d:%d" % (user.get("sub"), season["number"], today),
|
| 231 |
+
lambda: 0)
|
| 232 |
+
if mine >= DAILY_CAP:
|
| 233 |
+
raise HTTPException(429, "하루 제출 상한(%d건)에 도달했습니다." % DAILY_CAP)
|
| 234 |
+
|
| 235 |
+
rec = {"candidate_id": cid, "formula": s.formula.strip(),
|
| 236 |
+
"composition": comp, "n_atoms": info["n_atoms"],
|
| 237 |
+
"display_name": (s.display_name or "").strip()[:60],
|
| 238 |
+
"model_name": (s.model_name or "").strip()[:80],
|
| 239 |
+
"rationale": (s.rationale or "").strip()[:1000],
|
| 240 |
+
"visibility": "public" if s.visibility == "public" else "private",
|
| 241 |
+
"has_structure": bool(s.structure),
|
| 242 |
+
"hf_user": (user or {}).get("name", ""),
|
| 243 |
+
"hf_sub": (user or {}).get("sub", ""),
|
| 244 |
+
"season": season["number"],
|
| 245 |
+
"submitted_at": int(time.time()),
|
| 246 |
+
"uuid": uuid.uuid4().hex}
|
| 247 |
+
store.write(seasons.path(season, "submissions/%s.json" % cid), rec,
|
| 248 |
+
summary="submit %s" % cid)
|
| 249 |
+
if s.structure:
|
| 250 |
+
# 구조는 따로 둔다. 목록을 훑을 때 같이 끌려오면 순위표가 무거워진다.
|
| 251 |
+
store.write(seasons.path(season, "structures/%s.json" % cid),
|
| 252 |
+
{"candidate_id": cid, "cif": s.structure[:400_000]},
|
| 253 |
+
summary="structure %s" % cid)
|
| 254 |
+
CACHE.drop("ids:%d" % season["number"])
|
| 255 |
+
|
| 256 |
+
scored = set((_board(season).get("entries") or {}).keys())
|
| 257 |
+
ahead = len([i for i in _submissions(season) if i not in scored])
|
| 258 |
+
return {"accepted": True, "candidate_id": cid, "queue_position": ahead,
|
| 259 |
+
"note": "채점은 별도 계산 작업으로 처리되며 완료까지 시간이 걸립니다."}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ---------------------------------------------------------------- 순위표
|
| 263 |
+
def _anchors(season):
|
| 264 |
+
p = os.path.join(DATA, season.get("anchors") or "")
|
| 265 |
+
if not os.path.exists(p):
|
| 266 |
+
return []
|
| 267 |
+
out = []
|
| 268 |
+
for a in json.load(open(p, encoding="utf-8")):
|
| 269 |
+
if not a.get("admitted"):
|
| 270 |
+
continue
|
| 271 |
+
out.append({"candidate_id": a["label"], "is_anchor": True,
|
| 272 |
+
"display_name": "기준물질", "model_name": "",
|
| 273 |
+
"formula": a.get("formula", ""),
|
| 274 |
+
"total": a.get("total"), "total_max": a.get("total_max"),
|
| 275 |
+
"tier": a.get("tier", 1),
|
| 276 |
+
"axes": {k: v["points"] for k, v in (a.get("axes") or {}).items()},
|
| 277 |
+
"detail": {k: v["detail"] for k, v in (a.get("axes") or {}).items()},
|
| 278 |
+
"note": a.get("note", "")})
|
| 279 |
+
return out
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _leaderboard_body(season):
|
| 283 |
+
rows = [mask(r) for r in (_board(season).get("entries") or {}).values()]
|
| 284 |
+
anchors = _anchors(season)
|
| 285 |
+
merged = rows + anchors
|
| 286 |
+
merged.sort(key=lambda e: (e.get("tier", 1), -(e.get("total") if e.get("total") is not None else -1)))
|
| 287 |
+
n = 0
|
| 288 |
+
for e in merged:
|
| 289 |
+
if e.get("is_anchor") or e.get("tier", 1) != 1:
|
| 290 |
+
e["rank"] = None # 기준물질은 척도를 표시할 뿐 등수를 갖지 않는다
|
| 291 |
+
else:
|
| 292 |
+
n += 1
|
| 293 |
+
e["rank"] = n
|
| 294 |
+
return {"season": seasons.public(season), "entries": merged,
|
| 295 |
+
"counts": {"scored": len(rows), "anchors": len(anchors)}}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _leaderboard_bytes(season):
|
| 299 |
+
raw = json.dumps(_leaderboard_body(season), ensure_ascii=False).encode("utf-8")
|
| 300 |
+
return raw, gzip.compress(raw, 6)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@app.get("/api/leaderboard")
|
| 304 |
+
def leaderboard(request: Request):
|
| 305 |
+
"""응답은 시즌에만 의존하므로 TTL 당 한 번만 만들고 한 번만 압축한다."""
|
| 306 |
+
season = seasons.get(request.query_params.get("season"))
|
| 307 |
+
raw, gz = CACHE.get("lbresp:%d" % season["number"],
|
| 308 |
+
lambda: _leaderboard_bytes(season))
|
| 309 |
+
if "gzip" in (request.headers.get("accept-encoding") or ""):
|
| 310 |
+
return Response(content=gz, media_type="application/json",
|
| 311 |
+
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding"})
|
| 312 |
+
return Response(content=raw, media_type="application/json",
|
| 313 |
+
headers={"Vary": "Accept-Encoding"})
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@app.get("/api/queue")
|
| 317 |
+
def queue(request: Request):
|
| 318 |
+
season = seasons.get(request.query_params.get("season"))
|
| 319 |
+
ids = _submissions(season)
|
| 320 |
+
scored = set((_board(season).get("entries") or {}).keys())
|
| 321 |
+
return {"queued": len([i for i in ids if i not in scored]),
|
| 322 |
+
"scored": len(scored), "total": len(ids)}
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@app.get("/api/health")
|
| 326 |
+
def health():
|
| 327 |
+
return {"ok": True, "seasons": [s["number"] for s in seasons.SEASONS]}
|
data/anchor_scores.json
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"label": "LGPS",
|
| 4 |
+
"formula": "Li10GeP2S12",
|
| 5 |
+
"admitted": true,
|
| 6 |
+
"tier": 1,
|
| 7 |
+
"axes": {
|
| 8 |
+
"oxidation": {
|
| 9 |
+
"points": 2.73,
|
| 10 |
+
"detail": "산화한계 2.30 V · 상위 92%"
|
| 11 |
+
},
|
| 12 |
+
"li_stability": {
|
| 13 |
+
"points": 13.81,
|
| 14 |
+
"detail": "Li분해 +1.3035 eV/atom · 상위 54%"
|
| 15 |
+
},
|
| 16 |
+
"migration": {
|
| 17 |
+
"points": null,
|
| 18 |
+
"detail": "미평가(G2 대기)"
|
| 19 |
+
},
|
| 20 |
+
"novelty": {
|
| 21 |
+
"points": null,
|
| 22 |
+
"detail": "용도 신규성 미판정"
|
| 23 |
+
}
|
| 24 |
+
},
|
| 25 |
+
"total": 16.54,
|
| 26 |
+
"total_max": 65,
|
| 27 |
+
"note": "황화물. 이온전도도 12 mS/cm 로 최고 수준이나 산화창이 좁고 리튬금속과 반응한다.",
|
| 28 |
+
"measured": {
|
| 29 |
+
"ox": 2.2974683544303796,
|
| 30 |
+
"li": 1.3035463196661523,
|
| 31 |
+
"ea": null
|
| 32 |
+
}
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"label": "아지로다이트",
|
| 36 |
+
"formula": "Li6PS5Cl",
|
| 37 |
+
"admitted": true,
|
| 38 |
+
"tier": 1,
|
| 39 |
+
"axes": {
|
| 40 |
+
"oxidation": {
|
| 41 |
+
"points": 2.04,
|
| 42 |
+
"detail": "산화한계 2.13 V · 상위 94%"
|
| 43 |
+
},
|
| 44 |
+
"li_stability": {
|
| 45 |
+
"points": 19.57,
|
| 46 |
+
"detail": "Li분해 +0.9958 eV/atom · 상위 35%"
|
| 47 |
+
},
|
| 48 |
+
"migration": {
|
| 49 |
+
"points": 14.05,
|
| 50 |
+
"detail": "이동장벽 0.293 eV · 상위 6%"
|
| 51 |
+
},
|
| 52 |
+
"novelty": {
|
| 53 |
+
"points": null,
|
| 54 |
+
"detail": "용도 신규성 미판정"
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"total": 35.66,
|
| 58 |
+
"total_max": 80,
|
| 59 |
+
"note": "황화물. 상용화가 가장 앞선 계열.",
|
| 60 |
+
"measured": {
|
| 61 |
+
"ox": 2.132911392405063,
|
| 62 |
+
"li": 0.9958406943041193,
|
| 63 |
+
"ea": 0.293
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"label": "LPS",
|
| 68 |
+
"formula": "Li3PS4",
|
| 69 |
+
"admitted": true,
|
| 70 |
+
"tier": 1,
|
| 71 |
+
"axes": {
|
| 72 |
+
"oxidation": {
|
| 73 |
+
"points": 7.15,
|
| 74 |
+
"detail": "산화한계 2.71 V · 상위 80%"
|
| 75 |
+
},
|
| 76 |
+
"li_stability": {
|
| 77 |
+
"points": 10.54,
|
| 78 |
+
"detail": "Li분해 +1.4918 eV/atom · 상위 65%"
|
| 79 |
+
},
|
| 80 |
+
"migration": {
|
| 81 |
+
"points": 12.21,
|
| 82 |
+
"detail": "이동장벽 0.420 eV · 상위 19%"
|
| 83 |
+
},
|
| 84 |
+
"novelty": {
|
| 85 |
+
"points": null,
|
| 86 |
+
"detail": "용도 신규성 미판정"
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
"total": 29.9,
|
| 90 |
+
"total_max": 80,
|
| 91 |
+
"note": "황화물 기본형.",
|
| 92 |
+
"measured": {
|
| 93 |
+
"ox": 2.7088607594936707,
|
| 94 |
+
"li": 1.4918371727514401,
|
| 95 |
+
"ea": 0.4199
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"label": "LLZO",
|
| 100 |
+
"formula": "Li7La3Zr2O12",
|
| 101 |
+
"admitted": true,
|
| 102 |
+
"tier": 1,
|
| 103 |
+
"axes": {
|
| 104 |
+
"oxidation": {
|
| 105 |
+
"points": 11.89,
|
| 106 |
+
"detail": "산화한계 3.12 V · 상위 66%"
|
| 107 |
+
},
|
| 108 |
+
"li_stability": {
|
| 109 |
+
"points": 29.63,
|
| 110 |
+
"detail": "Li분해 +0.0221 eV/atom · 상위 1% · **불확실**(|값|<σ=0.467)"
|
| 111 |
+
},
|
| 112 |
+
"migration": {
|
| 113 |
+
"points": 11.23,
|
| 114 |
+
"detail": "이동장벽 0.459 eV · 상위 25%"
|
| 115 |
+
},
|
| 116 |
+
"novelty": {
|
| 117 |
+
"points": null,
|
| 118 |
+
"detail": "용도 신규성 미판정"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"total": 52.75,
|
| 122 |
+
"total_max": 80,
|
| 123 |
+
"note": "산화물 가넷. 리튬금속에 가장 안정한 축에 든다.",
|
| 124 |
+
"measured": {
|
| 125 |
+
"ox": 3.120253164556962,
|
| 126 |
+
"li": 0.022097652991613963,
|
| 127 |
+
"ea": 0.459
|
| 128 |
+
}
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"label": "LIC",
|
| 132 |
+
"formula": "Li3InCl6",
|
| 133 |
+
"admitted": true,
|
| 134 |
+
"tier": 1,
|
| 135 |
+
"axes": {
|
| 136 |
+
"oxidation": {
|
| 137 |
+
"points": 28.74,
|
| 138 |
+
"detail": "산화한계 4.27 V · 상위 18%"
|
| 139 |
+
},
|
| 140 |
+
"li_stability": {
|
| 141 |
+
"points": 22.62,
|
| 142 |
+
"detail": "Li분해 +0.8328 eV/atom · 상위 25%"
|
| 143 |
+
},
|
| 144 |
+
"migration": {
|
| 145 |
+
"points": 11.95,
|
| 146 |
+
"detail": "이동장벽 0.430 eV · 상위 20%"
|
| 147 |
+
},
|
| 148 |
+
"novelty": {
|
| 149 |
+
"points": null,
|
| 150 |
+
"detail": "용도 신규성 미판정"
|
| 151 |
+
}
|
| 152 |
+
},
|
| 153 |
+
"total": 63.31,
|
| 154 |
+
"total_max": 80,
|
| 155 |
+
"note": "할라이드. 4V 급 산화 안정성.",
|
| 156 |
+
"measured": {
|
| 157 |
+
"ox": 4.272151898734177,
|
| 158 |
+
"li": 0.8327522978304368,
|
| 159 |
+
"ea": 0.4297
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"label": "LZP",
|
| 164 |
+
"formula": "LiZr2(PO4)3",
|
| 165 |
+
"admitted": true,
|
| 166 |
+
"tier": 1,
|
| 167 |
+
"axes": {
|
| 168 |
+
"oxidation": {
|
| 169 |
+
"points": 31.36,
|
| 170 |
+
"detail": "산화한계 4.60 V · 상위 10%"
|
| 171 |
+
},
|
| 172 |
+
"li_stability": {
|
| 173 |
+
"points": 10.07,
|
| 174 |
+
"detail": "Li분해 +1.5212 eV/atom · 상위 66%"
|
| 175 |
+
},
|
| 176 |
+
"migration": {
|
| 177 |
+
"points": 14.83,
|
| 178 |
+
"detail": "이동장벽 0.185 eV · 상위 1%"
|
| 179 |
+
},
|
| 180 |
+
"novelty": {
|
| 181 |
+
"points": null,
|
| 182 |
+
"detail": "용도 신규성 미판정"
|
| 183 |
+
}
|
| 184 |
+
},
|
| 185 |
+
"total": 56.26,
|
| 186 |
+
"total_max": 80,
|
| 187 |
+
"note": "NASICON. 산화 안정성이 높다.",
|
| 188 |
+
"measured": {
|
| 189 |
+
"ox": 4.60126582278481,
|
| 190 |
+
"li": 1.521223317414708,
|
| 191 |
+
"ea": 0.1855
|
| 192 |
+
}
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"label": "LATP 모체",
|
| 196 |
+
"formula": "LiTi2(PO4)3",
|
| 197 |
+
"admitted": true,
|
| 198 |
+
"tier": 1,
|
| 199 |
+
"axes": {
|
| 200 |
+
"oxidation": {
|
| 201 |
+
"points": 32.07,
|
| 202 |
+
"detail": "산화한계 4.68 V · 상위 8%"
|
| 203 |
+
},
|
| 204 |
+
"li_stability": {
|
| 205 |
+
"points": 7.25,
|
| 206 |
+
"detail": "Li분해 +1.6956 eV/atom · 상위 76%"
|
| 207 |
+
},
|
| 208 |
+
"migration": {
|
| 209 |
+
"points": 14.3,
|
| 210 |
+
"detail": "이동장벽 0.264 eV · 상위 5%"
|
| 211 |
+
},
|
| 212 |
+
"novelty": {
|
| 213 |
+
"points": null,
|
| 214 |
+
"detail": "용도 신규성 미판정"
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
"total": 53.62,
|
| 218 |
+
"total_max": 80,
|
| 219 |
+
"note": "NASICON. Ti(4+) 가 리튬금속에 환원되어 리튬금속 전지에는 그대로 못 쓴다.",
|
| 220 |
+
"measured": {
|
| 221 |
+
"ox": 4.6835443037974684,
|
| 222 |
+
"li": 1.6956100674601606,
|
| 223 |
+
"ea": 0.2637
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
]
|
data/reference_dist.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"_meta": {"n_ox": 8749, "n_li": 8749, "n_ea": 1603, "frozen": "2026-08-21", "source": "Materials Project G1 공간 스크리닝", "note": "시즌 중 변경 금지. 바꾸면 이전 제출의 점수가 소급해 달라진다."}, "oxidation": [-0.5, 0.981, 1.2278, 1.6392, 1.8038, 1.9684, 2.1329, 2.2152, 2.2975, 2.2975, 2.2975, 2.2975, 2.3797, 2.3797, 2.3797, 2.462, 2.5443, 2.5443, 2.6266, 2.6266, 2.6266, 2.7089, 2.7089, 2.7911, 2.7911, 2.7911, 2.7911, 2.8734, 2.8734, 2.8734, 2.8734, 2.9557, 3.038, 3.038, 3.1203, 3.1203, 3.1203, 3.2025, 3.2025, 3.2025, 3.2025, 3.2848, 3.2848, 3.2848, 3.2848, 3.3671, 3.3671, 3.3671, 3.3671, 3.4494, 3.4494, 3.4494, 3.5316, 3.5316, 3.5316, 3.6139, 3.6139, 3.6962, 3.6962, 3.7785, 3.7785, 3.7785, 3.7785, 3.7785, 3.7785, 3.8608, 3.8608, 3.8608, 3.8608, 3.943, 3.943, 4.0253, 4.0253, 4.0253, 4.1076, 4.1076, 4.1076, 4.1076, 4.1076, 4.1899, 4.1899, 4.1899, 4.1899, 4.2722, 4.2722, 4.2722, 4.3544, 4.4367, 4.4367, 4.519, 4.6013, 4.6013, 4.6835, 4.7658, 4.8481, 4.9304, 5.1772, 5.8354, 6.0, 6.0, 6.0], "li_stability": [-1.3264, 0.0128, 0.0891, 0.1591, 0.2003, 0.2461, 0.2763, 0.3195, 0.3667, 0.4065, 0.4432, 0.4815, 0.5182, 0.5603, 0.5886, 0.6209, 0.6486, 0.6757, 0.6962, 0.7252, 0.7574, 0.7824, 0.7961, 0.8092, 0.8271, 0.8397, 0.8553, 0.8728, 0.8876, 0.9044, 0.9219, 0.9409, 0.9588, 0.9719, 0.9855, 1.0017, 1.0246, 1.0468, 1.0665, 1.086, 1.1111, 1.1292, 1.1524, 1.1672, 1.1857, 1.198, 1.2137, 1.2238, 1.2329, 1.244, 1.2545, 1.264, 1.2784, 1.2897, 1.3038, 1.323, 1.344, 1.3591, 1.3794, 1.3998, 1.4163, 1.4341, 1.4491, 1.4614, 1.4762, 1.4945, 1.5133, 1.531, 1.5529, 1.5739, 1.5926, 1.6131, 1.6309, 1.6495, 1.6697, 1.6822, 1.6993, 1.7188, 1.744, 1.7772, 1.8053, 1.8165, 1.8329, 1.8594, 1.8874, 1.9164, 1.9534, 1.9768, 2.0106, 2.0504, 2.0878, 2.1101, 2.166, 2.2003, 2.2512, 2.2814, 2.3346, 2.3905, 2.5073, 2.7434, 3.8359], "migration": [0.0781, 0.1758, 0.2051, 0.2344, 0.2441, 0.2637, 0.2832, 0.3027, 0.3125, 0.3223, 0.332, 0.3418, 0.3516, 0.3613, 0.3711, 0.3809, 0.3906, 0.4004, 0.4102, 0.4199, 0.4199, 0.4297, 0.4297, 0.4395, 0.4492, 0.4492, 0.459, 0.459, 0.4688, 0.4785, 0.4883, 0.498, 0.5078, 0.5078, 0.5176, 0.5273, 0.5371, 0.5371, 0.5469, 0.5566, 0.5664, 0.5664, 0.5762, 0.5859, 0.5945, 0.5957, 0.6055, 0.6244, 0.6348, 0.6348, 0.6445, 0.6543, 0.6641, 0.6836, 0.6836, 0.6934, 0.7031, 0.7129, 0.7227, 0.7324, 0.7422, 0.7422, 0.752, 0.7617, 0.7715, 0.7812, 0.791, 0.8008, 0.8203, 0.8398, 0.8496, 0.8691, 0.8789, 0.8984, 0.918, 0.9424, 0.9668, 0.9863, 1.0156, 1.0352, 1.0645, 1.084, 1.1035, 1.123, 1.1426, 1.1816, 1.2012, 1.2207, 1.2574, 1.2793, 1.3281, 1.3947, 1.4551, 1.543, 1.6699, 1.7676, 1.8449, 2.1967, 2.4203, 2.7822, 3.584]}
|
data/weights.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"weights": {
|
| 3 |
+
"oxidation": 35,
|
| 4 |
+
"li_stability": 30,
|
| 5 |
+
"novelty": 20,
|
| 6 |
+
"migration": 15
|
| 7 |
+
}
|
| 8 |
+
}
|
gates.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""접수 단계 검사.
|
| 3 |
+
|
| 4 |
+
여기서 보는 것은 **형식과 자격**뿐이다. 실제 채점(전기화학창·리튬 이동)은 워커가 한다.
|
| 5 |
+
스페이스는 MP 데이터도 GPU 도 없으므로, 여기서 물성을 판정하는 척하면 안 된다.
|
| 6 |
+
|
| 7 |
+
거절은 참가자가 **고칠 수 있는 것**에만 쓴다. 값을 얻지 못한 항목은 거절이 아니라 보류로 둔다.
|
| 8 |
+
"""
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
# 채점 대상 원소.
|
| 12 |
+
SUPPORTED = set("""
|
| 13 |
+
H Li Be B C N O F Na Mg Al Si P S Cl K Ca Sc Ti V Cr Mn Fe Co Ni Cu Zn
|
| 14 |
+
Ga Ge As Se Br Rb Sr Y Zr Nb Mo Tc Ru Rh Pd Ag Cd In Sn Sb Te I Cs Ba
|
| 15 |
+
La Ce Pr Nd Pm Sm Eu Gd Tb Dy Ho Er Tm Yb Lu Hf Ta W Re Os Ir Pt Au Hg
|
| 16 |
+
Tl Pb Bi Ac Th Pa U Np Pu
|
| 17 |
+
""".split())
|
| 18 |
+
|
| 19 |
+
_CIF_HINT = ("data_", "_cell_length_a", "_atom_site")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_formula(text):
|
| 23 |
+
"""화학식을 조성으로. **괄호를 반드시 처리한다.**
|
| 24 |
+
|
| 25 |
+
직접 정규식으로 훑으면 `LiZr2(PO4)3` 가 P1O4 가 되어 조성이 통째로 틀린다.
|
| 26 |
+
Materials Project 자신이 LGPS 를 `Li10Ge(PS6)2` 로 적으므로 이건 예외가 아니라 기본이다.
|
| 27 |
+
"""
|
| 28 |
+
from pymatgen.core import Composition
|
| 29 |
+
c = Composition(str(text).strip())
|
| 30 |
+
return {str(k): float(v) for k, v in c.get_el_amt_dict().items()}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def check(text, cif=None, require_elements=("Li",), max_atoms=60,
|
| 34 |
+
supported=None, max_elements=6):
|
| 35 |
+
"""접수 판정. (ok, reason, info) 를 돌려준다."""
|
| 36 |
+
supported = supported or SUPPORTED
|
| 37 |
+
raw = (text or "").strip()
|
| 38 |
+
if not raw:
|
| 39 |
+
return False, "화학식을 입력해 주세요.", {}
|
| 40 |
+
if len(raw) > 120:
|
| 41 |
+
return False, "화학식이 너무 깁니다.", {}
|
| 42 |
+
if not re.match(r"^[A-Za-z0-9()\[\]\.\s]+$", raw):
|
| 43 |
+
return False, "화학식에 쓸 수 없는 문자가 있습니다.", {}
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
comp = parse_formula(raw)
|
| 47 |
+
except Exception:
|
| 48 |
+
return False, "화학식을 해석하지 못했습니다. 예: Li3YCl6, LiZr2(PO4)3", {}
|
| 49 |
+
if not comp:
|
| 50 |
+
return False, "화학식을 해석하지 못했습니다.", {}
|
| 51 |
+
|
| 52 |
+
for el in require_elements:
|
| 53 |
+
if el not in comp:
|
| 54 |
+
return False, "리튬 전해질 시즌입니다. %s 를 포함해야 합니다." % el, {}
|
| 55 |
+
|
| 56 |
+
bad = sorted(e for e in comp if e not in supported)
|
| 57 |
+
if bad:
|
| 58 |
+
return False, ("계산 기준 상태가 없는 원소입니다: %s" % ", ".join(bad)), {}
|
| 59 |
+
|
| 60 |
+
if len(comp) > max_elements:
|
| 61 |
+
return False, "원소가 %d 종을 넘습니다 (현재 %d 종)." % (max_elements, len(comp)), {}
|
| 62 |
+
|
| 63 |
+
# **기약 조성**을 기준으로 삼는다. Li3YCl6 와 Li9Y3Cl18 은 같은 물질이므로 같은 것으로
|
| 64 |
+
# 세야 한다. 원본 조성으로 열쇠를 만들면 배수만 바꿔 같은 물질을 몇 번이고 올릴 수 있다.
|
| 65 |
+
from math import gcd
|
| 66 |
+
from functools import reduce
|
| 67 |
+
integral = all(abs(v - round(v)) < 1e-6 for v in comp.values())
|
| 68 |
+
if integral:
|
| 69 |
+
ints = {k: int(round(v)) for k, v in comp.items()}
|
| 70 |
+
g = reduce(gcd, ints.values()) or 1
|
| 71 |
+
reduced = {k: v // g for k, v in ints.items()}
|
| 72 |
+
else:
|
| 73 |
+
reduced = dict(comp) # 비정수 조성(도핑 등)은 그대로 둔다
|
| 74 |
+
n_atom = sum(reduced.values())
|
| 75 |
+
if n_atom > max_atoms:
|
| 76 |
+
return False, ("기약 조성의 원자 수가 %d 개로 상한 %d 를 넘습니다."
|
| 77 |
+
% (n_atom, max_atoms)), {}
|
| 78 |
+
|
| 79 |
+
info = {"composition": comp, "reduced": reduced,
|
| 80 |
+
"n_atoms": n_atom, "n_elements": len(comp)}
|
| 81 |
+
|
| 82 |
+
if cif:
|
| 83 |
+
c = str(cif)
|
| 84 |
+
if len(c) > 400_000:
|
| 85 |
+
return False, "구조 파일이 너무 큽니다 (400KB 상한).", {}
|
| 86 |
+
if not any(h in c for h in _CIF_HINT):
|
| 87 |
+
return False, "구조 파일이 CIF 형식으로 보이지 않습니다.", {}
|
| 88 |
+
info["has_structure"] = True
|
| 89 |
+
|
| 90 |
+
return True, "", info
|
index.html
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="ko">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>Open Materials Challenge #1 전고체 전해질</title>
|
| 7 |
+
<!-- <head> 는 선택이 아니다: 플랫폼이 자기 태그를 문서에 주입하는데, 받을 head 가 없으면
|
| 8 |
+
그것들이 doctype 앞에 놓이고 페이지 전체가 쿼크 모드로 떨어진다. -->
|
| 9 |
+
<style>
|
| 10 |
+
:root{
|
| 11 |
+
--bg:#f7f8fa; --card:#fff; --ink:#16181d; --muted:#6b7280; --line:#e5e7eb;
|
| 12 |
+
--accent:#1f6f4a; --accent-soft:#e8f3ed; --warn:#9a6700; --warn-soft:#fff8e1;
|
| 13 |
+
--anchor:#fffbe6; --anchor-line:#f2e3a8;
|
| 14 |
+
}
|
| 15 |
+
:root:not([data-theme="light"]){}
|
| 16 |
+
@media (prefers-color-scheme: dark){
|
| 17 |
+
:root:not([data-theme="light"]){
|
| 18 |
+
--bg:#101215; --card:#171a1f; --ink:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
|
| 19 |
+
--accent:#4bbb87; --accent-soft:#16281f; --warn:#e0b23c; --warn-soft:#2a2312;
|
| 20 |
+
--anchor:#241f10; --anchor-line:#4a4022;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
:root[data-theme="dark"]{
|
| 24 |
+
--bg:#101215; --card:#171a1f; --ink:#e8eaed; --muted:#9aa1ab; --line:#2a2f37;
|
| 25 |
+
--accent:#4bbb87; --accent-soft:#16281f; --warn:#e0b23c; --warn-soft:#2a2312;
|
| 26 |
+
--anchor:#241f10; --anchor-line:#4a4022;
|
| 27 |
+
}
|
| 28 |
+
*{box-sizing:border-box}
|
| 29 |
+
body{margin:0;background:var(--bg);color:var(--ink);
|
| 30 |
+
font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans KR",sans-serif}
|
| 31 |
+
.wrap{max-width:1120px;margin:0 auto;padding:20px 16px 80px}
|
| 32 |
+
header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin:8px 0 4px}
|
| 33 |
+
h1{font-size:22px;margin:0;letter-spacing:-.3px}
|
| 34 |
+
h2{font-size:17px;margin:0 0 10px;display:flex;align-items:baseline;gap:10px}
|
| 35 |
+
.sub{color:var(--muted);font-size:14px}
|
| 36 |
+
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;
|
| 37 |
+
padding:18px 20px;margin:14px 0}
|
| 38 |
+
.grid{display:grid;grid-template-columns:1fr 360px;gap:14px;align-items:start}
|
| 39 |
+
@media (max-width:900px){.grid{grid-template-columns:1fr}}
|
| 40 |
+
.cnt{color:var(--muted);font-size:13px;font-weight:400;margin-left:auto}
|
| 41 |
+
#langbar,#seasonbar{display:flex;gap:6px;flex-wrap:wrap}
|
| 42 |
+
#langbar{margin-left:auto}
|
| 43 |
+
#langbar button,#seasonbar button{border:1px solid var(--line);background:var(--card);
|
| 44 |
+
color:var(--muted);border-radius:8px;padding:5px 12px;cursor:pointer;font:inherit;font-size:13px}
|
| 45 |
+
#langbar button.on,#seasonbar button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
| 46 |
+
.scroll{overflow-x:auto}
|
| 47 |
+
table{width:100%;border-collapse:collapse;font-size:13.5px;min-width:780px}
|
| 48 |
+
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
| 49 |
+
th{color:var(--muted);font-weight:600;font-size:12.5px;cursor:pointer;user-select:none}
|
| 50 |
+
th .ar{opacity:.4;margin-left:3px;font-size:10px}
|
| 51 |
+
th.sorted{color:var(--accent)} th.sorted .ar{opacity:1}
|
| 52 |
+
tr.anchor{background:var(--anchor)}
|
| 53 |
+
tr.anchor td{border-bottom-color:var(--anchor-line)}
|
| 54 |
+
td.num{text-align:right;font-variant-numeric:tabular-nums}
|
| 55 |
+
.pill{display:inline-block;padding:1px 7px;border-radius:999px;font-size:11.5px;
|
| 56 |
+
background:var(--accent-soft);color:var(--accent);border:1px solid transparent}
|
| 57 |
+
.note{color:var(--muted);font-size:12.5px;margin-top:12px;line-height:1.7}
|
| 58 |
+
.pager{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;
|
| 59 |
+
padding:14px 4px 2px;font-size:13px}
|
| 60 |
+
.pager button{border:1px solid var(--line);background:var(--card);color:var(--muted);
|
| 61 |
+
border-radius:8px;padding:5px 11px;cursor:pointer;font:inherit;min-width:36px}
|
| 62 |
+
.pager button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
| 63 |
+
.pager button:disabled{opacity:.4;cursor:default}
|
| 64 |
+
.pager .pinfo{color:var(--muted);margin:0 8px}
|
| 65 |
+
.pager .gap{color:var(--muted);padding:0 2px}
|
| 66 |
+
label{display:block;font-size:13px;color:var(--muted);margin:12px 0 5px}
|
| 67 |
+
input,textarea,select{width:100%;padding:9px 11px;border:1px solid var(--line);
|
| 68 |
+
border-radius:9px;background:var(--bg);color:var(--ink);font:inherit;font-size:14px}
|
| 69 |
+
textarea{resize:vertical;min-height:64px}
|
| 70 |
+
.radio{display:flex;gap:8px;margin-top:6px}
|
| 71 |
+
.radio label{flex:1;display:flex;align-items:center;gap:7px;margin:0;padding:10px;
|
| 72 |
+
border:1px solid var(--line);border-radius:9px;cursor:pointer;font-size:13px;color:var(--ink)}
|
| 73 |
+
.radio label.on{border-color:var(--accent);background:var(--accent-soft)}
|
| 74 |
+
.go{width:100%;margin-top:16px;padding:12px;border:0;border-radius:10px;
|
| 75 |
+
background:var(--accent);color:#fff;font:inherit;font-size:15px;font-weight:600;cursor:pointer}
|
| 76 |
+
.go:disabled{opacity:.5;cursor:default}
|
| 77 |
+
.msg{margin-top:10px;padding:10px 12px;border-radius:9px;font-size:13px;display:none}
|
| 78 |
+
.msg.ok{display:block;background:var(--accent-soft);color:var(--accent)}
|
| 79 |
+
.msg.bad{display:block;background:var(--warn-soft);color:var(--warn)}
|
| 80 |
+
.callout{background:var(--warn-soft);border:1px solid var(--anchor-line);
|
| 81 |
+
border-radius:10px;padding:12px 14px;font-size:13px;color:var(--ink);margin:12px 0}
|
| 82 |
+
.axes{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 2px}
|
| 83 |
+
.axes span{font-size:12.5px;color:var(--muted);background:var(--bg);
|
| 84 |
+
border:1px solid var(--line);border-radius:999px;padding:3px 10px}
|
| 85 |
+
.axes b{color:var(--ink)}
|
| 86 |
+
a{color:var(--accent)}
|
| 87 |
+
.badges{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
|
| 88 |
+
.badges img{height:22px}
|
| 89 |
+
</style>
|
| 90 |
+
</head>
|
| 91 |
+
<body>
|
| 92 |
+
<div class="wrap">
|
| 93 |
+
|
| 94 |
+
<header>
|
| 95 |
+
<h1 id="t_title">Open Materials Challenge</h1>
|
| 96 |
+
<span class="sub" id="t_sub"></span>
|
| 97 |
+
<div id="langbar">
|
| 98 |
+
<button data-lang="ko" class="on">한국어</button>
|
| 99 |
+
<button data-lang="en">English</button>
|
| 100 |
+
</div>
|
| 101 |
+
</header>
|
| 102 |
+
<div class="badges" id="badges"></div>
|
| 103 |
+
|
| 104 |
+
<div id="seasonbar" style="margin:14px 0 0"></div>
|
| 105 |
+
|
| 106 |
+
<div class="card">
|
| 107 |
+
<h2 id="t_about_h"></h2>
|
| 108 |
+
<div id="t_about"></div>
|
| 109 |
+
<div class="axes" id="wts"></div>
|
| 110 |
+
<div class="callout" id="t_honest"></div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="grid">
|
| 114 |
+
<div class="card">
|
| 115 |
+
<h2><span id="t_board_h"></span><span class="cnt" id="counts"></span></h2>
|
| 116 |
+
<div class="scroll">
|
| 117 |
+
<table>
|
| 118 |
+
<thead><tr id="head"></tr></thead>
|
| 119 |
+
<tbody id="rows"></tbody>
|
| 120 |
+
</table>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="pager" id="pager"></div>
|
| 123 |
+
<div class="note" id="t_boardnote"></div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<div class="card">
|
| 127 |
+
<h2 id="t_submit_h"></h2>
|
| 128 |
+
<div id="gate" class="callout" style="display:none"></div>
|
| 129 |
+
<label id="l_formula"></label>
|
| 130 |
+
<input id="f_formula" placeholder="Li3YCl6">
|
| 131 |
+
<label id="l_name"></label>
|
| 132 |
+
<input id="f_name" placeholder="">
|
| 133 |
+
<label id="l_model"></label>
|
| 134 |
+
<input id="f_model" placeholder="anthropic/claude-…, Qwen/…">
|
| 135 |
+
<label id="l_cif"></label>
|
| 136 |
+
<textarea id="f_cif" placeholder="# generated using pymatgen data_…"></textarea>
|
| 137 |
+
<div class="note" id="t_cifnote" style="margin-top:4px"></div>
|
| 138 |
+
<label id="l_why"></label>
|
| 139 |
+
<textarea id="f_why"></textarea>
|
| 140 |
+
<label id="l_vis"></label>
|
| 141 |
+
<div class="radio" id="visbox">
|
| 142 |
+
<label class="on"><input type="radio" name="vis" value="private" checked><span id="t_priv"></span></label>
|
| 143 |
+
<label><input type="radio" name="vis" value="public"><span id="t_pub"></span></label>
|
| 144 |
+
</div>
|
| 145 |
+
<button class="go" id="go"></button>
|
| 146 |
+
<div class="msg" id="msg"></div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="card">
|
| 151 |
+
<h2 id="t_broker_h"></h2>
|
| 152 |
+
<div id="t_broker"></div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
</div>
|
| 156 |
+
<script>
|
| 157 |
+
const T = {
|
| 158 |
+
ko:{
|
| 159 |
+
sub:"인류의 난제를 AI 와 함께 — 개인이 참여하는 신소재 탐색",
|
| 160 |
+
about_h:"이 대회는 무엇인가",
|
| 161 |
+
about:`<p>전고체 배터리는 리튬이온 전지의 액체 전해질을 고체로 바꾼 것입니다. 더 안전하고 더 오래 갈 수 있지만,
|
| 162 |
+
<b>고체이면서 리튬만 잘 통과시키는 물질</b>을 찾는 일이 아직 풀리지 않았습니다.</p>
|
| 163 |
+
<p>이 대회는 그 후보 물질을 찾습니다. 참가자는 <b>조성 하나</b>를 냅니다 — 예: <code>Li3YCl6</code>.
|
| 164 |
+
저희가 계산으로 채점해 순위표에 올립니다. 상금은 없습니다.</p>`,
|
| 165 |
+
honest:`<b>이 시즌이 채점하는 것.</b> <b>전기화학적 강건성</b>입니다 — 높은 전압을 견디는지,
|
| 166 |
+
리튬 금속에 닿아도 버티는지, 리튬이 지나갈 길이 뚫려 있는지.
|
| 167 |
+
<b>이온전도도는 이번 시즌의 채점 항목이 아닙니다.</b> 따라서 전도도가 높은 계열이라도
|
| 168 |
+
산화창이 좁거나 리튬 금속과 반응하면 낮게 나옵니다 — 그 두 가지가 이 시즌이 묻는 문제입니다.
|
| 169 |
+
모든 값은 <b>계산 추정치</b>이며 실제 성능이나 안전성을 뜻하지 않습니다.`,
|
| 170 |
+
board_h:"순위표",
|
| 171 |
+
boardnote:`<b>기준물질(노란 행)</b>은 점수를 받되 등수를 갖지 않습니다. 실제로 쓰이는 전해질이 몇 점인지
|
| 172 |
+
눈으로 비교하시라고 넣었습니다.<br>
|
| 173 |
+
비공개 제출은 조성을 가리고 <b>원소 종류만</b> 표시합니다. 원본은 비공개로 보관됩니다.<br>
|
| 174 |
+
열 제목을 눌러 정렬할 수 있습니다.`,
|
| 175 |
+
submit_h:"제출",
|
| 176 |
+
l_formula:"조성 (화학식)", l_name:"표시 ID (이름·소속·닉네임)", l_model:"사용 모델명 (선택)",
|
| 177 |
+
l_cif:"구조 파일 CIF (선택)", l_why:"설계 근거 (선택)", l_vis:"공개 여부",
|
| 178 |
+
cifnote:`구조를 함께 내면 채점이 더 빠르고 정확합니다. 없으면 저희가 구조를 추정하며,
|
| 179 |
+
추정 구조를 얻지 못한 조성은 결과가 <b>보류</b>로 표시됩니다.`,
|
| 180 |
+
priv:"🔒 비공개 (기본)", pub:"🔓 조성 공개",
|
| 181 |
+
go:"제출하기",
|
| 182 |
+
gate:"제출하려면 <b>Hugging Face 로그인</b>이 필요합니다. 제출자 계정이 순위표에 함께 표시되며, 이는 다른 사람 이름으로 올리는 것을 막습니다.",
|
| 183 |
+
broker_h:"제출 기록과 제3자 연결",
|
| 184 |
+
broker:`<p>제출은 시각과 함께 저희 원장에 기록됩니다. 조성을 <b>비공개</b>로 두면 저희만 보관하며,
|
| 185 |
+
공개하지 않는 한 외부에 드러나지 않습니다. 이 기록이 향후 권리 주장에 참고가 될 수 있으나,
|
| 186 |
+
<b>특허 출원을 대신하지 않으며 법적 효력을 보장하지 않습니다.</b> 출원은 전문가와 상의하십시오.</p>
|
| 187 |
+
<p>제3자가 특정 제출에 대해 협의를 요청하는 경우, 운영자가 <b>중개</b>합니다.
|
| 188 |
+
제출자의 신원이나 연락처는 <b>본인 동의 없이 전달하지 않습니다.</b></p>`,
|
| 189 |
+
cols:{rank:"#",cand:"후보",user:"제출자",who:"모델",date:"제출일",
|
| 190 |
+
oxidation:"산화",li_stability:"Li안정",migration:"이동",novelty:"신규",total:"총점"},
|
| 191 |
+
counts:(a,b)=>`제출 ${a} · 기준물질 ${b}`,
|
| 192 |
+
prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} / 전체 ${n.toLocaleString()}`,
|
| 193 |
+
empty:"아직 채점된 제출이 없습니다.",
|
| 194 |
+
fail:"순위표를 불러오지 못했습니다: ",
|
| 195 |
+
ok:(id,q)=>`접수되었습니다. 후보 ID <b>${id}</b> · 대기 ${q}건`,
|
| 196 |
+
need:"조성과 표시 ID를 입력해 주세요.",
|
| 197 |
+
hold:"보류"
|
| 198 |
+
},
|
| 199 |
+
en:{
|
| 200 |
+
sub:"Open problems, worked on together — materials discovery anyone can join",
|
| 201 |
+
about_h:"What this is",
|
| 202 |
+
about:`<p>A solid-state battery replaces the liquid electrolyte of a lithium-ion cell with a solid.
|
| 203 |
+
It can be safer and last longer, but finding a material that is <b>solid and still lets lithium through</b>
|
| 204 |
+
remains unsolved.</p>
|
| 205 |
+
<p>This challenge looks for candidates. You submit <b>one composition</b> — for example <code>Li3YCl6</code>.
|
| 206 |
+
We score it computationally and place it on the board. There is no prize.</p>`,
|
| 207 |
+
honest:`<b>What this season scores.</b> <b>Electrochemical robustness</b>: whether the material holds up at
|
| 208 |
+
high voltage, survives contact with lithium metal, and has a path for lithium to move through.
|
| 209 |
+
<b>Ionic conductivity is not a scored axis this season.</b> A highly conductive family therefore still
|
| 210 |
+
scores low if its oxidation window is narrow or it reacts with lithium metal — those two are the problem
|
| 211 |
+
this season puts to you.
|
| 212 |
+
Every value is a <b>computational estimate</b> and implies nothing about real performance or safety.`,
|
| 213 |
+
board_h:"Leaderboard",
|
| 214 |
+
boardnote:`<b>Reference materials (amber rows)</b> are scored but hold no rank. They are there so you can see
|
| 215 |
+
where materials in actual use land.<br>
|
| 216 |
+
Private entries show <b>elements only</b>; the composition is held back.<br>
|
| 217 |
+
Click a column header to sort.`,
|
| 218 |
+
submit_h:"Submit",
|
| 219 |
+
l_formula:"Composition (formula)", l_name:"Display ID (name, affiliation or handle)",
|
| 220 |
+
l_model:"Model used (optional)", l_cif:"Structure file, CIF (optional)",
|
| 221 |
+
l_why:"Design rationale (optional)", l_vis:"Visibility",
|
| 222 |
+
cifnote:`Including a structure makes scoring faster and more accurate. Without one we estimate the
|
| 223 |
+
structure; where no estimated structure is obtained, the result is marked <b>held</b>.`,
|
| 224 |
+
priv:"🔒 Private (default)", pub:"🔓 Show composition",
|
| 225 |
+
go:"Submit",
|
| 226 |
+
gate:"Submitting requires a <b>Hugging Face sign-in</b>. Your account appears alongside the entry, which stops anyone posting under another person's name.",
|
| 227 |
+
broker_h:"Record of submission, and third-party contact",
|
| 228 |
+
broker:`<p>Entries are recorded with a timestamp in our ledger. A <b>private</b> composition stays with us and
|
| 229 |
+
is not disclosed unless you choose to publish it. This record may serve as a reference later, but it
|
| 230 |
+
<b>is not a patent filing and carries no guaranteed legal effect.</b> Consult a professional before filing.</p>
|
| 231 |
+
<p>If a third party asks to discuss a specific entry, we pass the request along. We never share a
|
| 232 |
+
submitter's identity or contact details <b>without their consent.</b></p>`,
|
| 233 |
+
cols:{rank:"#",cand:"Entry",user:"Submitter",who:"Model",date:"Date",
|
| 234 |
+
oxidation:"Oxid.",li_stability:"Li-stab.",migration:"Migration",novelty:"Novelty",total:"Total"},
|
| 235 |
+
counts:(a,b)=>`${a} entries · ${b} references`,
|
| 236 |
+
prange:(a,b,n)=>`${a.toLocaleString()}–${b.toLocaleString()} of ${n.toLocaleString()}`,
|
| 237 |
+
empty:"No entries have been scored yet.",
|
| 238 |
+
fail:"Could not load the leaderboard: ",
|
| 239 |
+
ok:(id,q)=>`Accepted. Entry <b>${id}</b> · ${q} in queue`,
|
| 240 |
+
need:"Please enter a composition and a display ID.",
|
| 241 |
+
hold:"held"
|
| 242 |
+
}
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
const $ = s => document.querySelector(s);
|
| 246 |
+
const COLS = ["rank","cand","user","who","date","oxidation","li_stability","migration","novelty","total"];
|
| 247 |
+
// 표가 커지면 한꺼번에 그리지 않고 한 페이지씩 그린다.
|
| 248 |
+
const PAGE_SIZE = 500;
|
| 249 |
+
let LANG = "ko", DATA = null, SEASONS = [], SEASON = 1;
|
| 250 |
+
let sortKey = "total", sortDir = -1, page = 0;
|
| 251 |
+
const t = () => T[LANG];
|
| 252 |
+
|
| 253 |
+
function esc(s){ return String(s ?? "").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
| 254 |
+
|
| 255 |
+
function setLang(l){
|
| 256 |
+
LANG = l;
|
| 257 |
+
document.documentElement.lang = l;
|
| 258 |
+
document.querySelectorAll("#langbar button").forEach(b=>b.classList.toggle("on", b.dataset.lang===l));
|
| 259 |
+
const d = t();
|
| 260 |
+
$("#t_sub").textContent = d.sub;
|
| 261 |
+
$("#t_about_h").textContent = d.about_h;
|
| 262 |
+
$("#t_about").innerHTML = d.about;
|
| 263 |
+
$("#t_honest").innerHTML = d.honest;
|
| 264 |
+
$("#t_board_h").textContent = d.board_h;
|
| 265 |
+
$("#t_boardnote").innerHTML = d.boardnote;
|
| 266 |
+
$("#t_submit_h").textContent = d.submit_h;
|
| 267 |
+
$("#t_broker_h").textContent = d.broker_h;
|
| 268 |
+
$("#t_broker").innerHTML = d.broker;
|
| 269 |
+
$("#t_cifnote").innerHTML = d.cifnote;
|
| 270 |
+
$("#t_priv").textContent = d.priv;
|
| 271 |
+
$("#t_pub").textContent = d.pub;
|
| 272 |
+
$("#go").textContent = d.go;
|
| 273 |
+
for(const k of ["formula","name","model","cif","why","vis"]) $("#l_"+k).textContent = d["l_"+k];
|
| 274 |
+
const g = $("#gate"); if(g.style.display !== "none") g.innerHTML = d.gate;
|
| 275 |
+
renderSeasonBar();
|
| 276 |
+
if(DATA) render();
|
| 277 |
+
}
|
| 278 |
+
document.querySelectorAll("#langbar button").forEach(b=>b.onclick=()=>setLang(b.dataset.lang));
|
| 279 |
+
|
| 280 |
+
function renderSeasonBar(){
|
| 281 |
+
$("#seasonbar").innerHTML = SEASONS.slice().sort((a,b)=>b.number-a.number).map(s=>{
|
| 282 |
+
const nm = LANG==="ko" ? (s.topic_ko||s.topic) : s.topic;
|
| 283 |
+
return `<button data-season="${s.number}" class="${s.number===SEASON?"on":""}">
|
| 284 |
+
#${s.number} ${esc(nm)}</button>`;
|
| 285 |
+
}).join("");
|
| 286 |
+
document.querySelectorAll("#seasonbar button").forEach(b=>
|
| 287 |
+
b.onclick=()=>{ SEASON=parseInt(b.dataset.season,10); page=0; renderSeasonBar(); load(); });
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function sortVal(e,k){
|
| 291 |
+
if(k==="rank") return e.rank ?? 1e9;
|
| 292 |
+
if(k==="cand") return String(e.candidate_id||"").toLowerCase();
|
| 293 |
+
if(k==="user") return String(e.hf_user||e.display_name||"").toLowerCase();
|
| 294 |
+
if(k==="who") return String(e.model_name||"").toLowerCase();
|
| 295 |
+
if(k==="date") return e.submitted_at ?? 0;
|
| 296 |
+
if(k==="total") return e.total ?? -1;
|
| 297 |
+
return (e.axes||{})[k] ?? -1;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function renderPager(total,pages){
|
| 301 |
+
const host = $("#pager"), d = t();
|
| 302 |
+
if(pages<=1){ host.innerHTML=""; return; }
|
| 303 |
+
const from = page*PAGE_SIZE+1, to = Math.min(total,(page+1)*PAGE_SIZE);
|
| 304 |
+
let nums=[];
|
| 305 |
+
if(pages<=9){ for(let i=0;i<pages;i++) nums.push(i); }
|
| 306 |
+
else{
|
| 307 |
+
const near=[page-1,page,page+1].filter(i=>i>0&&i<pages-1);
|
| 308 |
+
nums=[...new Set([0,...near,pages-1])].sort((a,b)=>a-b);
|
| 309 |
+
}
|
| 310 |
+
let out=`<button ${page===0?"disabled":""} data-p="${page-1}">‹</button>`, prev=-1;
|
| 311 |
+
for(const i of nums){
|
| 312 |
+
if(i-prev>1) out+=`<span class="gap">…</span>`;
|
| 313 |
+
out+=`<button class="${i===page?"on":""}" data-p="${i}">${i+1}</button>`; prev=i;
|
| 314 |
+
}
|
| 315 |
+
out+=`<button ${page>=pages-1?"disabled":""} data-p="${page+1}">›</button>`;
|
| 316 |
+
out+=`<span class="pinfo">${d.prange(from,to,total)}</span>`;
|
| 317 |
+
host.innerHTML=out;
|
| 318 |
+
host.querySelectorAll("button[data-p]").forEach(b=>b.onclick=()=>{
|
| 319 |
+
if(b.disabled) return;
|
| 320 |
+
page=parseInt(b.dataset.p,10); render();
|
| 321 |
+
const c=document.querySelector(".card .scroll");
|
| 322 |
+
if(c) window.scrollTo({top:c.getBoundingClientRect().top+window.scrollY-80,behavior:"smooth"});
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function render(){
|
| 327 |
+
const d = t(), W = (DATA.season && DATA.season.weights) || {};
|
| 328 |
+
$("#wts").innerHTML = Object.entries(W)
|
| 329 |
+
.map(([k,v])=>`<span>${esc(d.cols[k]||k)} <b>${v}</b></span>`).join("");
|
| 330 |
+
$("#counts").textContent = d.counts(DATA.counts.scored, DATA.counts.anchors);
|
| 331 |
+
|
| 332 |
+
$("#head").innerHTML = COLS.map(k=>{
|
| 333 |
+
const on = sortKey===k, ar = on ? (sortDir<0?"▼":"▲") : "▾";
|
| 334 |
+
return `<th data-k="${k}" class="${on?"sorted":""}">${esc(d.cols[k]||k)}<span class="ar">${ar}</span></th>`;
|
| 335 |
+
}).join("");
|
| 336 |
+
document.querySelectorAll("#head th").forEach(th=>th.onclick=()=>{
|
| 337 |
+
const k=th.dataset.k;
|
| 338 |
+
if(sortKey===k) sortDir=-sortDir;
|
| 339 |
+
else { sortKey=k; sortDir=(k==="rank"||k==="cand"||k==="who")?1:-1; }
|
| 340 |
+
page=0; render();
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
const all = DATA.entries.slice().sort((a,b)=>{
|
| 344 |
+
const x=sortVal(a,sortKey), y=sortVal(b,sortKey);
|
| 345 |
+
if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
|
| 346 |
+
});
|
| 347 |
+
const pages = Math.max(1, Math.ceil(all.length/PAGE_SIZE));
|
| 348 |
+
if(page>=pages) page=pages-1;
|
| 349 |
+
if(page<0) page=0;
|
| 350 |
+
const rows = all.slice(page*PAGE_SIZE, page*PAGE_SIZE+PAGE_SIZE);
|
| 351 |
+
renderPager(all.length, pages);
|
| 352 |
+
|
| 353 |
+
$("#rows").innerHTML = rows.length ? rows.map(e=>{
|
| 354 |
+
const a = e.axes||{};
|
| 355 |
+
const cls = e.is_anchor ? "anchor" : "";
|
| 356 |
+
const who = e.is_anchor ? esc(e.display_name) : esc(e.hf_user||e.display_name||"");
|
| 357 |
+
const num = k => {
|
| 358 |
+
const v = a[k];
|
| 359 |
+
return `<td class="num">${v==null?`<span class="pill">${d.hold}</span>`:v.toFixed(1)}</td>`;
|
| 360 |
+
};
|
| 361 |
+
const dt = e.submitted_at ? new Date(e.submitted_at*1000).toISOString().slice(0,10) : "";
|
| 362 |
+
const tot = e.total==null ? `<span class="pill">${d.hold}</span>`
|
| 363 |
+
: `${e.total.toFixed(1)}${e.total_max?`<span class="sub" style="font-size:11px"> /${e.total_max}</span>`:""}`;
|
| 364 |
+
return `<tr class="${cls}">
|
| 365 |
+
<td class="num">${e.rank ?? "—"}</td>
|
| 366 |
+
<td>${esc(e.formula||e.candidate_id||"")}${e.masked?' <span class="pill">🔒</span>':""}</td>
|
| 367 |
+
<td>${who}</td><td>${esc(e.model_name||"")}</td><td>${dt}</td>
|
| 368 |
+
${num("oxidation")}${num("li_stability")}${num("migration")}${num("novelty")}
|
| 369 |
+
<td class="num"><b>${tot}</b></td></tr>`;
|
| 370 |
+
}).join("") : `<tr><td colspan="${COLS.length}" class="sub">${d.empty}</td></tr>`;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
async function load(){
|
| 374 |
+
try{
|
| 375 |
+
const r = await fetch(`/api/leaderboard?season=${SEASON}`);
|
| 376 |
+
DATA = await r.json();
|
| 377 |
+
render();
|
| 378 |
+
}catch(e){
|
| 379 |
+
$("#rows").innerHTML = `<tr><td colspan="${COLS.length}" class="sub">${t().fail}${esc(e)}</td></tr>`;
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
document.querySelectorAll('input[name="vis"]').forEach(r=>r.onchange=()=>{
|
| 384 |
+
document.querySelectorAll("#visbox label").forEach(l=>l.classList.toggle("on", l.querySelector("input").checked));
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
$("#go").onclick = async () => {
|
| 388 |
+
const d = t(), m = $("#msg");
|
| 389 |
+
const formula = $("#f_formula").value.trim(), name = $("#f_name").value.trim();
|
| 390 |
+
if(!formula || !name){ m.className="msg bad"; m.textContent=d.need; return; }
|
| 391 |
+
$("#go").disabled = true;
|
| 392 |
+
try{
|
| 393 |
+
const r = await fetch("/api/submit",{method:"POST",headers:{"Content-Type":"application/json"},
|
| 394 |
+
body:JSON.stringify({formula, display_name:name, model_name:$("#f_model").value.trim(),
|
| 395 |
+
rationale:$("#f_why").value.trim(), structure:$("#f_cif").value.trim(),
|
| 396 |
+
visibility:document.querySelector('input[name="vis"]:checked').value, season:SEASON})});
|
| 397 |
+
const j = await r.json();
|
| 398 |
+
if(!r.ok){ m.className="msg bad"; m.textContent = j.detail || ("HTTP "+r.status); }
|
| 399 |
+
else{ m.className="msg ok"; m.innerHTML = d.ok(j.candidate_id, j.queue_position);
|
| 400 |
+
$("#f_formula").value=""; $("#f_cif").value=""; load(); }
|
| 401 |
+
}catch(e){ m.className="msg bad"; m.textContent=String(e); }
|
| 402 |
+
$("#go").disabled = false;
|
| 403 |
+
};
|
| 404 |
+
|
| 405 |
+
(async function boot(){
|
| 406 |
+
try{
|
| 407 |
+
const s = await (await fetch("/api/seasons")).json();
|
| 408 |
+
SEASONS = s.seasons || [];
|
| 409 |
+
const open = SEASONS.filter(x=>x.open);
|
| 410 |
+
SEASON = (open.length?open:SEASONS).slice(-1)[0]?.number || 1;
|
| 411 |
+
}catch(e){}
|
| 412 |
+
try{
|
| 413 |
+
const me = await (await fetch("/api/me")).json();
|
| 414 |
+
if(me.oauth_configured && !me.signed_in){
|
| 415 |
+
const g=$("#gate"); g.style.display="block"; g.innerHTML=t().gate;
|
| 416 |
+
}
|
| 417 |
+
}catch(e){}
|
| 418 |
+
setLang("ko");
|
| 419 |
+
await load();
|
| 420 |
+
setInterval(load, 20000);
|
| 421 |
+
})();
|
| 422 |
+
</script>
|
| 423 |
+
</body>
|
| 424 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
pydantic==2.9.2
|
| 4 |
+
pymatgen==2024.8.9
|
seasons.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""시즌 등록부.
|
| 3 |
+
|
| 4 |
+
시즌마다 대상 물질군·게이트·배점이 다르다. 한 프로세스가 모든 시즌을 서빙하므로
|
| 5 |
+
설정은 **환경변수가 아니라 인자**로 흐른다 - 환경변수로 두면 시즌 하나의 설정이
|
| 6 |
+
다른 시즌 요청에 새어 들어간다 (ODC 에서 실제로 그럴 뻔했다).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
SEASONS = [
|
| 10 |
+
{
|
| 11 |
+
"number": 1,
|
| 12 |
+
"name": "Open Materials Challenge",
|
| 13 |
+
"topic": "Solid-State Battery Electrolyte",
|
| 14 |
+
"topic_ko": "전고체 배터리 전해질",
|
| 15 |
+
"mobile_ion": "Li",
|
| 16 |
+
"opens": "2026-08-21",
|
| 17 |
+
"closes": "2026-11-30",
|
| 18 |
+
"prize": 0,
|
| 19 |
+
"open": True,
|
| 20 |
+
"prefix": "",
|
| 21 |
+
"anchors": "anchor_scores.json",
|
| 22 |
+
|
| 23 |
+
# 게이트는 자격만 본다. 성능은 점수로 반영한다.
|
| 24 |
+
"gate": {
|
| 25 |
+
"gap_min": 1.0, # 전자전도체는 전지를 단락시킨다 - 진짜 요건
|
| 26 |
+
"hull_max": 0.10, # 합성 가능성 상한
|
| 27 |
+
"ea_max": 1.0, # 리튬 관통 경로 존재 여부. 이진 판정
|
| 28 |
+
"max_atoms": 60,
|
| 29 |
+
"require_elements": ("Li",),
|
| 30 |
+
},
|
| 31 |
+
"weights": {
|
| 32 |
+
"oxidation": 35, # 산화 안정성
|
| 33 |
+
"li_stability": 30, # 리튬금속 안정성
|
| 34 |
+
"novelty": 20, # 용도 신규성 (조성 신규성이 아니다)
|
| 35 |
+
"migration": 15, # 리튬 이동 용이성
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
BY_NUMBER = {s["number"]: s for s in SEASONS}
|
| 41 |
+
DEFAULT = SEASONS[-1]
|
| 42 |
+
|
| 43 |
+
# 공개면에 내보내지 않는 키. 채점기 내부 사정이 새면 표적화된 제출이 들어온다.
|
| 44 |
+
_PRIVATE = ("prefix", "anchors", "gate")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get(n=None):
|
| 48 |
+
if n in (None, ""):
|
| 49 |
+
return DEFAULT
|
| 50 |
+
try:
|
| 51 |
+
return BY_NUMBER.get(int(n), DEFAULT)
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
return DEFAULT
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def gate(s):
|
| 57 |
+
return dict(s.get("gate") or {})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def path(s, name):
|
| 61 |
+
return (s.get("prefix") or "") + name
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def public(s):
|
| 65 |
+
out = {k: v for k, v in s.items() if k not in _PRIVATE}
|
| 66 |
+
g = s.get("gate") or {}
|
| 67 |
+
# 참가자가 알아야 반려를 피할 수 있는 값만 공개한다.
|
| 68 |
+
out["limits"] = {"max_atoms": g.get("max_atoms"),
|
| 69 |
+
"hull_max": g.get("hull_max"),
|
| 70 |
+
"gap_min": g.get("gap_min")}
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def all_public():
|
| 75 |
+
return [public(s) for s in SEASONS]
|
store.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""The submission ledger, kept in a private Hugging Face dataset.
|
| 3 |
+
|
| 4 |
+
A Space's container filesystem is ephemeral. Every rebuild, restart or sleep wipes it,
|
| 5 |
+
and with it every submission and every score. That is survivable for a demo and not
|
| 6 |
+
survivable for a contest with a prize attached, so the ledger lives outside the container
|
| 7 |
+
in a dataset repo that the entrant's work outlives the service.
|
| 8 |
+
|
| 9 |
+
Layout - one file per record, never appended to:
|
| 10 |
+
|
| 11 |
+
submissions/<id>.json written by the web service when an entry is accepted
|
| 12 |
+
results/<id>.json written by the GPU worker when it finishes scoring
|
| 13 |
+
leaderboard.json rolled up by the worker so the page reads one file
|
| 14 |
+
|
| 15 |
+
One file per record is what makes concurrent writers safe. Two entrants submitting at the
|
| 16 |
+
same moment touch different paths, so neither commit can clobber the other - which an
|
| 17 |
+
append to a shared JSONL absolutely would.
|
| 18 |
+
|
| 19 |
+
The rollup exists because a page load must not fan out into one request per entry. The
|
| 20 |
+
worker already holds every score at the moment it writes one, so it is the natural place
|
| 21 |
+
to rebuild the table.
|
| 22 |
+
"""
|
| 23 |
+
import base64
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import threading
|
| 27 |
+
import time
|
| 28 |
+
import urllib.error
|
| 29 |
+
import urllib.request
|
| 30 |
+
|
| 31 |
+
REPO = os.environ.get("OMC_DATASET", "FINAL-Bench/omc-submissions")
|
| 32 |
+
TOKEN = os.environ.get("HF_TOKEN", "")
|
| 33 |
+
API = "https://huggingface.co/api/datasets/%s" % REPO
|
| 34 |
+
RESOLVE = "https://huggingface.co/datasets/%s/resolve/main" % REPO
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _hdr(extra=None):
|
| 38 |
+
h = {"User-Agent": "VIDRAFT-OMC/1.0"}
|
| 39 |
+
if TOKEN:
|
| 40 |
+
h["Authorization"] = "Bearer " + TOKEN
|
| 41 |
+
if extra:
|
| 42 |
+
h.update(extra)
|
| 43 |
+
return h
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _get_json(url, timeout=30):
|
| 47 |
+
req = urllib.request.Request(url, headers=_hdr())
|
| 48 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 49 |
+
return json.loads(r.read().decode())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def read(path, default=None):
|
| 53 |
+
"""Fetch one record. Missing files are a normal state, not an error."""
|
| 54 |
+
try:
|
| 55 |
+
return _get_json("%s/%s" % (RESOLVE, path))
|
| 56 |
+
except urllib.error.HTTPError as e:
|
| 57 |
+
if e.code in (404, 401, 403):
|
| 58 |
+
return default
|
| 59 |
+
raise
|
| 60 |
+
except Exception:
|
| 61 |
+
return default
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def write(path, obj, summary=None):
|
| 65 |
+
"""Commit one record. NDJSON is the format this endpoint takes - a plain JSON body
|
| 66 |
+
is accepted and then quietly does nothing, which is a long way to debug."""
|
| 67 |
+
blob = base64.b64encode(json.dumps(obj, ensure_ascii=False).encode()).decode()
|
| 68 |
+
lines = [
|
| 69 |
+
json.dumps({"key": "header", "value": {"summary": summary or ("write " + path)}}),
|
| 70 |
+
json.dumps({"key": "file", "value": {"path": path, "content": blob,
|
| 71 |
+
"encoding": "base64"}}),
|
| 72 |
+
]
|
| 73 |
+
body = ("\n".join(lines) + "\n").encode()
|
| 74 |
+
req = urllib.request.Request(API + "/commit/main", data=body,
|
| 75 |
+
headers=_hdr({"Content-Type": "application/x-ndjson"}))
|
| 76 |
+
with urllib.request.urlopen(req, timeout=60) as r:
|
| 77 |
+
return json.loads(r.read().decode())
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
MAX_PAGES = int(os.environ.get("OMC_MAX_PAGES", "200"))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _tree_pages(prefix):
|
| 84 |
+
"""Every page of a tree listing, following the Link cursor.
|
| 85 |
+
|
| 86 |
+
The Hub caps a page at 1,000 entries. Reading only the first page was silent while
|
| 87 |
+
the dataset was small and started dropping records the moment it was not."""
|
| 88 |
+
url = "%s/tree/main/%s" % (API, prefix)
|
| 89 |
+
seen = 0
|
| 90 |
+
for _ in range(MAX_PAGES):
|
| 91 |
+
req = urllib.request.Request(url, headers=_hdr())
|
| 92 |
+
with urllib.request.urlopen(req, timeout=60) as r:
|
| 93 |
+
page = json.loads(r.read().decode())
|
| 94 |
+
link = r.headers.get("Link") or ""
|
| 95 |
+
yield page
|
| 96 |
+
seen += len(page)
|
| 97 |
+
nxt = ""
|
| 98 |
+
for part in link.split(","):
|
| 99 |
+
if 'rel="next"' in part and "<" in part:
|
| 100 |
+
nxt = part[part.index("<") + 1:part.index(">")]
|
| 101 |
+
if not nxt:
|
| 102 |
+
return
|
| 103 |
+
url = nxt
|
| 104 |
+
raise RuntimeError("tree listing exceeded %d pages at %s (%d entries)"
|
| 105 |
+
% (MAX_PAGES, prefix, seen))
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def listdir(prefix):
|
| 109 |
+
"""Record ids under a prefix. Absent directory means nothing has been written yet."""
|
| 110 |
+
out = []
|
| 111 |
+
try:
|
| 112 |
+
for page in _tree_pages(prefix):
|
| 113 |
+
for e in page:
|
| 114 |
+
q = e.get("path", "")
|
| 115 |
+
if q.endswith(".json") and not os.path.basename(q).startswith("_"):
|
| 116 |
+
out.append(os.path.basename(q)[:-5])
|
| 117 |
+
except urllib.error.HTTPError as e:
|
| 118 |
+
if e.code == 404:
|
| 119 |
+
return []
|
| 120 |
+
raise
|
| 121 |
+
except Exception:
|
| 122 |
+
# a partial listing is worse than none: the caller would treat the missing ids as
|
| 123 |
+
# unscored and the rollup would drop them, which is exactly the failure this fixes
|
| 124 |
+
raise
|
| 125 |
+
return out
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class Cached:
|
| 129 |
+
"""A small TTL cache so a page refresh does not become a round trip to the Hub.
|
| 130 |
+
|
| 131 |
+
Staleness is bounded and harmless here: the worst case is a leaderboard a few seconds
|
| 132 |
+
behind, and the page polls anyway.
|
| 133 |
+
|
| 134 |
+
**Single flight.** The first version had no lock, so every concurrent request that
|
| 135 |
+
arrived after the TTL expired saw a miss and every one of them re-ran produce(). With
|
| 136 |
+
a few thousand entries that is a full paginated tree listing each, and on a 2-vCPU
|
| 137 |
+
container the request threadpool fills with blocked Hub I/O - at which point even the
|
| 138 |
+
static index page queues behind it and the site stops answering. One refresher at a
|
| 139 |
+
time; everyone else is served the value we already have.
|
| 140 |
+
|
| 141 |
+
**Stale beats blocking, and stale beats an error.** A leaderboard a minute old is a
|
| 142 |
+
working page. A timeout is not.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
def __init__(self, ttl=20):
|
| 146 |
+
self.ttl = ttl
|
| 147 |
+
self._v = {}
|
| 148 |
+
self._locks = {}
|
| 149 |
+
self._guard = threading.Lock()
|
| 150 |
+
|
| 151 |
+
def _lock_for(self, key):
|
| 152 |
+
with self._guard:
|
| 153 |
+
lk = self._locks.get(key)
|
| 154 |
+
if lk is None:
|
| 155 |
+
lk = self._locks[key] = threading.Lock()
|
| 156 |
+
return lk
|
| 157 |
+
|
| 158 |
+
def get(self, key, produce):
|
| 159 |
+
hit = self._v.get(key)
|
| 160 |
+
if hit and time.time() - hit[0] < self.ttl:
|
| 161 |
+
return hit[1]
|
| 162 |
+
|
| 163 |
+
lk = self._lock_for(key)
|
| 164 |
+
# Block only when there is nothing at all to serve. If a refresh is already in
|
| 165 |
+
# flight and we hold a stale value, hand that back instead of joining the queue.
|
| 166 |
+
if not lk.acquire(blocking=(hit is None)):
|
| 167 |
+
return hit[1]
|
| 168 |
+
try:
|
| 169 |
+
hit = self._v.get(key) # the winner may have filled it while we waited
|
| 170 |
+
if hit and time.time() - hit[0] < self.ttl:
|
| 171 |
+
return hit[1]
|
| 172 |
+
try:
|
| 173 |
+
val = produce()
|
| 174 |
+
except Exception:
|
| 175 |
+
if hit:
|
| 176 |
+
return hit[1]
|
| 177 |
+
raise
|
| 178 |
+
self._v[key] = (time.time(), val)
|
| 179 |
+
return val
|
| 180 |
+
finally:
|
| 181 |
+
lk.release()
|
| 182 |
+
|
| 183 |
+
def drop(self, key=None):
|
| 184 |
+
if key is None:
|
| 185 |
+
self._v.clear()
|
| 186 |
+
else:
|
| 187 |
+
self._v.pop(key, None)
|