"""SOB2 · 13 · Universos de dos sobres (decisión del orquestador, 27-oct-2026): ACREA-ES-01-2024 (capa 0) y V-FIRMES-AND-26 (capa 1). Base = la de SOB1 (py/10_universos.py) sobre unified_20261025 (+ .vinculos, .identidad): unidad='adjudicacion'; lote = (source, licitacion_id, lote) de las filas que cuentan (NOT es_copia: copia_de, copia_otra_fuente o NOT cuenta en .vinculos/filas). Cambio declarado frente a SOB1: los hechos se TRASLADAN por el grupo de vínculos (grupo_contrato): un lote toma los hechos de cualquier fila de su grupo (en Andalucía la fila que cuenta es la autonómica, sin nº de ofertas; los hechos están en la copia de la PLACSP; es el traslado que D5b hace con la etiqueta). Hechos: D1 capa 1 (colusion/interno/sc_impl1, pct_s3), capa 0 (BAJA_COND de D5b, DEFINICIONES_D1C0 §1-2, recalculada aquí desde colusion/d5b/interno/c0_p0_crudo con la fórmula sellada y cotejada contra c0_sc), OU1, VC1, RS1. Sin empresas: solo ids en md5. Ninguna verdad: no se lee ninguna resolución ni lista de licitaciones citadas. Salidas: /s/salidas/universos/.parquet · /s/interno/universos_mapa_.parquet · /s/sob2/salidas/13_universos_sob2.json.""" import duckdb, json, os, time t0 = time.time() con = duckdb.connect() con.execute(f"SET memory_limit='{os.environ.get('DUCK_MEM', '1536MB')}'; SET threads={os.environ.get('DUCK_THREADS', '2')}; SET temp_directory='/s/sob2/tmp/duck'; SET preserve_insertion_order=false") D = '/d/unified_20261025'; I = D + '.identidad/'; A = '/a' OUT = os.environ.get('OUT', '/s') # prueba: OUT=/s/sob2/tmp/u1 (700); definitivo: /s con.execute(f"CREATE VIEW u AS SELECT * FROM read_parquet('{D}/*/*/*.parquet', hive_partitioning=1, union_by_name=1)") log = lambda *a: print(f'[{time.time()-t0:6.0f}s]', *a, flush=True) q = lambda s: con.execute(s).fetchall() PROV = "lower(strip_accents(coalesce(provincia,'')))" OBJ = "lower(strip_accents(coalesce(objeto,'')))" ORG = "lower(strip_accents(coalesce(organo_nombre,'')))" AND_ = f"(ccaa='AN' OR nuts LIKE 'ES61%' OR {PROV} IN ('almeria','cadiz','cordoba','granada','huelva','jaen','malaga','sevilla'))" APAE = f"({ORG} LIKE '%agencia publica andaluza de educacion%' OR {ORG} LIKE '%infraestructuras y servicios educativos%' OR {ORG} LIKE '%ise andalucia%')" OBRAS = "(tipo_contrato='Obras' OR cpv_division='45')" FIRME = f"({OBJ} LIKE '%firme%' OR {OBJ} LIKE '%bitumin%' OR {OBJ} LIKE '%aglomerado%' OR cpv_code LIKE '4523322%')" EXP = { 'ACREA-ES-01-2024': dict( nombre='Servicios de arquitectura (redacción de proyectos de obras) para la Agencia Pública Andaluza de Educación · Andalucía', ampliado=f"((cpv_division='71' OR {OBJ} LIKE '%redaccion%proyecto%') AND {AND_})", mercado=f"(cpv_division='71' OR {OBJ} LIKE '%redaccion%proyecto%')", nucleo=f"(cpv_code LIKE '7121%' OR cpv_code LIKE '7122%' OR cpv_code LIKE '7124%' OR cpv_code LIKE '7125%' OR {OBJ} LIKE '%redaccion%proyecto%')", organos=APAE, territorio=AND_, periodo=('2018-01-01', '2024-01-30')), 'V-FIRMES-AND-26': dict( nombre='[vigilancia] Mantenimiento y rehabilitación de firmes de carreteras · Consejería de Fomento de la Junta de Andalucía', ampliado=f"({OBRAS} AND {FIRME})", # nacional: el resto de España da los controles del mismo mercado mercado=f"({OBRAS} AND {FIRME})", nucleo=f"({OBJ} LIKE '%firme%')", organos=(f"(tipo_organo='autonomica' AND ({ORG} LIKE '%fomento%' OR {ORG} LIKE '%direccion general de infraestructuras%'" f" OR {ORG} LIKE '%carreteras%') AND NOT {ORG} LIKE '%agencia de obra publica%')"), territorio=AND_, periodo=('2018-01-01', '2023-11-16')), } amp = " OR ".join(f"({e['ampliado']})" for e in EXP.values()) con.execute(f"""CREATE TEMP TABLE b0 AS SELECT source, id_canonical, coalesce(licitacion_id, source||':'||id_canonical) licitacion_id, coalesce(lote_id, licitacion_id, id_canonical) lote_u, coalesce(regexp_extract(lote_id, '\\|([^|]*)$', 1), '') lote_k, anio_ref, cpv_code, cpv_division, tipo_contrato, procedimiento, coalesce(es_menor, FALSE) es_menor, num_ofertas, imp_adj_eur_comparable imp, imp_lic_eur, organo_nif, organo_nombre, dir3, ccaa, nuts, provincia, objeto, expediente, coalesce(fecha_adj, fecha_formalizacion, fecha_pub, make_date(anio_ref::INT, 7, 1)) fref, copia_de, correccion_imp_adj FROM u WHERE unidad='adjudicacion' AND anio_ref BETWEEN 2014 AND 2026 AND ({amp})""") log('b0', q("SELECT count(*) FROM b0")) # vínculos solo de las filas de b0 y de los miembros de sus grupos (semiuniones; agrupar el fichero entero no cabe en 2 GB) con.execute(f"CREATE TEMP VIEW vfa AS SELECT source, id_canonical, cuenta, grupo_contrato FROM read_parquet('{D}.vinculos/filas.parquet')") con.execute("""CREATE TEMP TABLE vb AS SELECT v.source, v.id_canonical, any_value(v.cuenta) cuenta, any_value(v.grupo_contrato) grupo FROM vfa v SEMI JOIN b0 ON b0.source=v.source AND b0.id_canonical=v.id_canonical GROUP BY 1, 2""") con.execute("""CREATE TEMP TABLE vf AS SELECT v.source, v.id_canonical, any_value(v.cuenta) cuenta, any_value(v.grupo_contrato) grupo FROM vfa v SEMI JOIN (SELECT DISTINCT grupo FROM vb WHERE grupo IS NOT NULL) g ON g.grupo=v.grupo_contrato GROUP BY 1, 2""") log('vínculos', q("SELECT count(*) FROM vb"), q("SELECT count(*), count(DISTINCT grupo) FROM vf")) con.execute(f"""CREATE TEMP TABLE b AS SELECT b0.*, (b0.copia_de IS NOT NULL OR coalesce(b0.correccion_imp_adj='copia_otra_fuente', FALSE) OR coalesce(NOT v.cuenta, FALSE)) es_copia, coalesce(v.grupo, 'f:'||b0.source||':'||b0.id_canonical) grupo, o.organo_clave, coalesce(t.tipo,'sin_tipo') tipo_organo FROM b0 LEFT JOIN vf v ON v.source=b0.source AND v.id_canonical=b0.id_canonical LEFT JOIN (SELECT organo_nif, organo_nombre, dir3, any_value(organo_clave) organo_clave FROM read_parquet('{I}organos_filas.parquet') GROUP BY ALL) o ON o.organo_nif IS NOT DISTINCT FROM b0.organo_nif AND o.organo_nombre IS NOT DISTINCT FROM b0.organo_nombre AND o.dir3 IS NOT DISTINCT FROM b0.dir3 LEFT JOIN (SELECT organo_clave, any_value(tipo) tipo FROM read_parquet('{I}organos_tipo.parquet') GROUP BY 1) t ON t.organo_clave=o.organo_clave""") log('b', q("SELECT count(*), sum(es_copia::INT), count(DISTINCT grupo) FROM b")) # --- miembros de los grupos de las filas que cuentan (cualquier fuente), y sus claves para VC1 (segunda pasada, semiunión) --- con.execute("""CREATE TEMP TABLE gm AS SELECT DISTINCT g.grupo, v.source, v.id_canonical FROM (SELECT DISTINCT grupo FROM b WHERE NOT es_copia AND grupo NOT LIKE 'f:%') g JOIN vf v ON v.grupo=g.grupo UNION SELECT DISTINCT grupo, source, id_canonical FROM b WHERE NOT es_copia""") con.execute("""CREATE TEMP TABLE gmk AS SELECT x.source, x.id_canonical, coalesce(x.licitacion_id, x.source||':'||x.id_canonical) licitacion_id, coalesce(regexp_extract(x.lote_id, '\\|([^|]*)$', 1), '') lote_k, x.expediente FROM u x SEMI JOIN gm ON gm.source=x.source AND gm.id_canonical=x.id_canonical WHERE x.unidad='adjudicacion' AND x.source IN (SELECT DISTINCT source FROM gm)""") log('miembros', q("SELECT count(*), count(DISTINCT grupo) FROM gm"), q("SELECT count(*) FROM gmk")) # --- hechos por fila --- con.execute(f"CREATE TEMP TABLE h_d1 AS SELECT id_canonical, any_value(en_c) en_c, max(pct_s3) pct_s3, max(baja) baja, max(baja_cond) baja_cond, max(rango) rango, max(sep) sep, max(pnc) pnc FROM read_parquet('{A}/colusion/interno/sc_impl1.parquet') GROUP BY 1") con.execute(f"CREATE TEMP TABLE h_ou AS SELECT id_min id_canonical, bool_or(unica) unica, max(p) p FROM read_parquet('{A}/oferta_unica/interno/lotes_p.parquet') GROUP BY 1") con.execute(f"""CREATE TEMP TABLE h_vc AS SELECT licitacion_id, lote_k, bool_or(fondo) fondo, max(tipo_cambio) tipo, max(subtipo) subtipo, max(p) p FROM read_parquet('{A}/versiones/interno/lotes_p.parquet') GROUP BY 1, 2""") con.execute(f"CREATE TEMP TABLE h_rs AS SELECT DISTINCT source, id_canonical FROM read_parquet('{A}/redes_societarias/interno/40_lotes_hermanos.parquet')") con.execute(f"CREATE TEMP TABLE h_rc AS SELECT DISTINCT organo_clave, cpv2, anio FROM read_parquet('{A}/redes_societarias/interno/40_celda.parquet')") # capa 0: BAJA_COND y su percentil orientado (↓) dentro de fuente × CPV × tipo de órgano × año, fórmula de D5b (c0_screens.py, sellada) con.execute(f"""CREATE TEMP TABLE c0s1 AS SELECT id_canonical, source, cpv, tipo, anio, num_ofertas, CASE WHEN num_ofertas = 3 THEN '3' WHEN num_ofertas <= 5 THEN '4-5' WHEN num_ofertas <= 9 THEN '6-9' ELSE '10+' END tramo, CASE WHEN pbl_lote > 0 AND adj / pbl_lote <= 1.25 THEN 1 - adj / pbl_lote END baja, count(*) OVER (PARTITION BY source, cpv, tipo, anio) n_merc FROM read_parquet('{A}/colusion/d5b/interno/c0_p0_crudo.parquet')""") con.execute("""CREATE TEMP TABLE c0med AS SELECT source, cpv, tipo, anio, tramo, median(baja) med FROM c0s1 WHERE baja IS NOT NULL GROUP BY ALL HAVING count(baja) >= 10""") con.execute("""CREATE TEMP TABLE c0s2 AS SELECT s.*, (s.n_merc >= 20) en_c, CASE WHEN m.med IS NOT NULL AND s.baja IS NOT NULL THEN s.baja - m.med END baja_cond FROM c0s1 s LEFT JOIN c0med m USING (source, cpv, tipo, anio, tramo)""") part = "PARTITION BY en_c, baja_cond IS NULL, source, cpv, tipo, anio"; o = "round(-1 * baja_cond, 12)" con.execute(f"""CREATE TEMP TABLE h_c0 AS SELECT id_canonical, source c0_source, cpv c0_cpv, tipo c0_tipo, anio c0_anio, baja c0_baja, baja_cond c0_baja_cond, CASE WHEN en_c AND baja_cond IS NOT NULL THEN ((rank() OVER ({part} ORDER BY {o}) + count(*) OVER ({part} ORDER BY {o} RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) / 2.0 - 0.5) / count(*) OVER ({part}) END c0_p_baja FROM c0s2""") cot = q(f"""SELECT count(*), max(abs(h.c0_p_baja - s.p_baja)), count(*) FILTER (WHERE (h.c0_p_baja IS NULL) <> (s.p_baja IS NULL)) FROM read_parquet('{A}/colusion/d5b/interno/c0_sc.parquet') s JOIN h_c0 h USING (id_canonical)""")[0] log('cotejo capa 0 frente a c0_sc (D5b impl1)', cot) assert cot[0] > 1000 and (cot[1] is None or cot[1] <= 1e-9) and cot[2] == 0, 'la capa 0 recalculada no reproduce la de D5b' # --- hechos por grupo (traslado por vínculos) --- con.execute("""CREATE TEMP TABLE hg AS SELECT gm.grupo, bool_or(d1.id_canonical IS NOT NULL) d1_en_p, bool_or(coalesce(d1.en_c, FALSE)) d1_en_c, max(d1.pct_s3) d1_pct_s3, max(d1.baja) d1_baja, max(d1.baja_cond) d1_baja_cond, max(d1.rango) d1_rango, max(d1.sep) d1_sep, max(d1.pnc) d1_pnc, bool_or(c0.id_canonical IS NOT NULL) c0_en_p0, max(c0.c0_baja) c0_baja, max(c0.c0_baja_cond) c0_baja_cond, max(c0.c0_p_baja) c0_p_baja, bool_or(ou.id_canonical IS NOT NULL) ou1_evaluable, bool_or(ou.unica) ou1_unica, max(ou.p) ou1_p, bool_or(rs.id_canonical IS NOT NULL) rs1_h, count(DISTINCT gm.source||gm.id_canonical) filas_grupo, bool_or(gm.source <> 'ANDALUCIA' AND (d1.id_canonical IS NOT NULL OR c0.id_canonical IS NOT NULL OR ou.id_canonical IS NOT NULL)) hechos_de_otra_fila FROM gm LEFT JOIN h_d1 d1 ON d1.id_canonical=gm.id_canonical LEFT JOIN h_c0 c0 ON c0.id_canonical=gm.id_canonical LEFT JOIN h_ou ou ON ou.id_canonical=gm.id_canonical LEFT JOIN h_rs rs ON rs.source=gm.source AND rs.id_canonical=gm.id_canonical GROUP BY 1""") con.execute("""CREATE TEMP TABLE hv AS SELECT gm.grupo, bool_or(vc.licitacion_id IS NOT NULL) vc1_evaluable, bool_or(vc.fondo) vc1_fondo, max(vc.tipo) vc1_tipo, max(vc.subtipo) vc1_subtipo, max(vc.p) vc1_p FROM gm JOIN gmk k ON k.source=gm.source AND k.id_canonical=gm.id_canonical LEFT JOIN h_vc vc ON vc.licitacion_id=k.licitacion_id AND vc.lote_k=k.lote_k GROUP BY 1""") res = {'generado': time.strftime('%Y-%m-%d %H:%M:%S %Z'), 'base': D, 'cotejo_capa0': dict(filas=cot[0], max_delta=cot[1], nulos_distintos=cot[2]), 'expedientes': {}} for exp, e in EXP.items(): vi, vf_ = e['periodo'] con.execute(f"""CREATE OR REPLACE TEMP TABLE r AS SELECT *, ({e['mercado']}) en_mercado, ({e['nucleo']}) AND ({e['mercado']}) en_nucleo, ({e['organos']}) en_organos, ({e['territorio']}) en_territorio, fref BETWEEN DATE '{vi}' AND DATE '{vf_}' en_periodo FROM b WHERE NOT es_copia AND ({e['ampliado']})""") con.execute(f"""CREATE OR REPLACE TEMP TABLE lot AS SELECT '{exp}' exp, md5(r.source||'|'||r.licitacion_id||'|'||r.lote_u) id_lote, md5(r.source||'|'||r.licitacion_id) id_lic, r.source, any_value(r.anio_ref)::INT anio, min(r.fref) fref, any_value(r.cpv_division) cpv_div, any_value(substr(r.cpv_code,1,4)) cpv4, any_value(r.tipo_contrato) tipo_contrato, any_value(r.procedimiento) procedimiento, bool_or(r.es_menor) es_menor, any_value(r.tipo_organo) tipo_organo, md5(coalesce(any_value(r.organo_clave),'')) id_organo, bool_or(r.en_mercado) en_mercado, bool_or(r.en_nucleo) en_nucleo, bool_or(r.en_organos) en_organos, bool_or(r.en_territorio) en_territorio, bool_or(r.en_periodo) en_periodo, max(r.num_ofertas) num_ofertas, sum(r.imp) imp_adj, max(r.imp_lic_eur) imp_lic, count(*) filas, max(hg.filas_grupo) filas_grupo, bool_or(coalesce(hg.hechos_de_otra_fila, FALSE)) hechos_trasladados, bool_or(coalesce(hg.ou1_evaluable, FALSE)) ou1_evaluable, bool_or(hg.ou1_unica) ou1_unica, max(hg.ou1_p) ou1_p, bool_or(coalesce(hv.vc1_evaluable, FALSE)) vc1_evaluable, bool_or(hv.vc1_fondo) vc1_fondo, max(hv.vc1_tipo) vc1_tipo, max(hv.vc1_subtipo) vc1_subtipo, max(hv.vc1_p) vc1_p, bool_or(rc.organo_clave IS NOT NULL) rs1_celda_evaluable, CASE WHEN bool_or(rc.organo_clave IS NOT NULL OR coalesce(hg.rs1_h, FALSE)) THEN bool_or(coalesce(hg.rs1_h, FALSE)) END rs1_hermana, bool_or(coalesce(hg.d1_en_p, FALSE)) d1_en_p, bool_or(coalesce(hg.d1_en_c, FALSE)) d1_en_c, max(hg.d1_pct_s3) d1_pct_s3, max(hg.d1_baja) d1_baja, max(hg.d1_baja_cond) d1_baja_cond, max(hg.d1_rango) d1_rango, max(hg.d1_sep) d1_sep, max(hg.d1_pnc) d1_pnc, bool_or(coalesce(hg.c0_en_p0, FALSE)) c0_en_p0, max(hg.c0_baja) c0_baja, max(hg.c0_baja_cond) c0_baja_cond, max(hg.c0_p_baja) c0_p_baja, any_value(r.licitacion_id) licitacion_id, any_value(r.lote_u) lote_u, any_value(r.expediente) expediente, list(DISTINCT r.grupo) grupos FROM r LEFT JOIN hg ON hg.grupo=r.grupo LEFT JOIN hv ON hv.grupo=r.grupo LEFT JOIN h_rc rc ON rc.organo_clave=r.organo_clave AND rc.cpv2=r.cpv_division AND rc.anio=r.anio_ref GROUP BY r.source, r.licitacion_id, r.lote_u""") con.execute(f"COPY (SELECT * EXCLUDE (licitacion_id, lote_u, expediente, grupos) FROM lot ORDER BY id_lote) TO '{OUT}/salidas/universos/{exp}.parquet' (FORMAT parquet)") # mapa de apertura: lote -> filas de su grupo (fuente, licitación, expediente de contratación); sin empresas ni id_canonical con.execute(f"""COPY (SELECT DISTINCT l.exp, l.id_lote, l.id_lic, l.source, l.licitacion_id, l.lote_u, l.expediente, k.source miembro_source, k.licitacion_id miembro_licitacion_id, k.expediente miembro_expediente FROM (SELECT *, unnest(grupos) grupo FROM lot WHERE en_mercado AND en_periodo AND en_organos AND en_territorio) l JOIN gm ON gm.grupo=l.grupo JOIN gmk k ON k.source=gm.source AND k.id_canonical=gm.id_canonical ORDER BY 2, 8, 9) TO '{OUT}/interno/universos_mapa_{exp}.parquet' (FORMAT parquet)""") U = "en_mercado AND en_periodo AND en_organos AND en_territorio AND NOT es_menor" def cuenta(w): x = q(f"""SELECT count(*), count(DISTINCT id_lic), count(DISTINCT id_organo), count(*) FILTER (WHERE num_ofertas IS NOT NULL), count(*) FILTER (WHERE d1_pct_s3 IS NOT NULL), count(*) FILTER (WHERE d1_pct_s3 >= 0.8), count(*) FILTER (WHERE c0_p_baja IS NOT NULL), count(*) FILTER (WHERE c0_p_baja >= 0.8), count(*) FILTER (WHERE ou1_evaluable), count(*) FILTER (WHERE hechos_trasladados), round(sum(imp_adj)/1e6, 2) FROM lot WHERE {w}""")[0] return dict(zip(['lotes', 'licitaciones', 'organos', 'con_nofertas', 'con_capa1', 'capa1_ge_p80', 'con_capa0', 'capa0_ge_p80', 'ou1_evaluable', 'con_hechos_trasladados', 'imp_adj_meur'], x)) R = dict(nombre=e['nombre'], periodo=e['periodo'], universo_expresion=U, universo=cuenta(U), universo_con_menores=cuenta("en_mercado AND en_periodo AND en_organos AND en_territorio"), por_fuente=q(f"SELECT source, count(*), count(d1_pct_s3), count(c0_p_baja) FROM lot WHERE {U} GROUP BY 1 ORDER BY 2 DESC"), por_anio=q(f"SELECT anio, count(*), count(d1_pct_s3), count(c0_p_baja) FROM lot WHERE {U} GROUP BY 1 ORDER BY 1"), organos=q(f"SELECT any_value(tipo_organo), count(*) FROM lot WHERE {U} GROUP BY id_organo ORDER BY 2 DESC LIMIT 40"), lotes_parquet=q("SELECT count(*) FROM lot")[0][0]) if exp == 'V-FIRMES-AND-26': # referencia: mismo mercado en el resto de España, mismo periodo, no menores, con capa 1 R['referencia_capa1'] = q("""SELECT count(*) FILTER (WHERE d1_pct_s3 IS NOT NULL), count(*) FILTER (WHERE d1_pct_s3 >= 0.8), count(*) FILTER (WHERE d1_pct_s3 >= 0.8) * 1.0 / nullif(count(*) FILTER (WHERE d1_pct_s3 IS NOT NULL), 0) FROM lot WHERE en_mercado AND en_periodo AND NOT es_menor AND NOT (en_organos AND en_territorio)""")[0] else: # referencia: P0 de CPV 71 (fuente × 71 × tipo × año) de toda España fuera de la APAE y de los grupos del universo, 2018-2023 con.execute(f"""CREATE OR REPLACE TEMP TABLE excl AS SELECT DISTINCT gm.id_canonical FROM gm JOIN (SELECT unnest(grupos) grupo FROM lot WHERE en_organos) x ON x.grupo=gm.grupo""") con.execute(f"""CREATE OR REPLACE TEMP TABLE aorg AS SELECT DISTINCT organo_clave FROM b WHERE ({APAE}) AND organo_clave IS NOT NULL""") R['referencia_capa0'] = q(f"""SELECT count(*), count(*) FILTER (WHERE h.c0_p_baja >= 0.8), count(*) FILTER (WHERE h.c0_p_baja >= 0.8) * 1.0 / nullif(count(*), 0) FROM h_c0 h JOIN read_parquet('{A}/colusion/d5b/interno/c0_p0_crudo.parquet') p USING (id_canonical) WHERE h.c0_p_baja IS NOT NULL AND h.c0_cpv='71' AND h.c0_anio BETWEEN 2018 AND 2023 AND h.id_canonical NOT IN (SELECT id_canonical FROM excl) AND p.org NOT IN (SELECT organo_clave FROM aorg)""")[0] res['expedientes'][exp] = R log(exp, R['universo'], R.get('referencia_capa1') or R.get('referencia_capa0')) json.dump(res, open(OUT + ('/sob2/salidas' if OUT == '/s' else '') + '/13_universos_sob2.json', 'w'), ensure_ascii=False, indent=1, default=str) log('fin')