diff --git a/analysis/views/clustering.py b/analysis/views/clustering.py index b4e6e2d..2978de3 100644 --- a/analysis/views/clustering.py +++ b/analysis/views/clustering.py @@ -359,6 +359,36 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm, except Exception as svd_err: logger.warning(f'Cluster SVD extraction skipped: {svd_err}') + # ── Step 2c: Save per-row cluster labels to EntityProfile ── + # Always persist row→cluster mapping so cluster_detail can show data rows + # even when UMAP is skipped or fails. + try: + cluster_entry_save = store.get_cluster_result(cluster_id) + labels_list_save = cluster_entry_save.get('labels', []) if cluster_entry_save else [] + if labels_list_save: + from analysis.models import EntityProfile as EP_save + from analysis.models import ClusterResult as CR_save + # Delete stale EP records for this run first + EP_save.objects.filter(run=run).delete() + cr_cache_save = {} + row_profiles = [] + for r_idx in range(len(labels_list_save)): + lbl = labels_list_save[r_idx] + ck = f'{run.id}_{lbl}' + if ck not in cr_cache_save: + cr_cache_save[ck] = CR_save.objects.filter(run=run, cluster_label=lbl).first() + row_profiles.append(EP_save( + entity_value=f'row_{r_idx}', run=run, cluster_label=lbl, + cluster=cr_cache_save[ck], + embedding_x=None, embedding_y=None, embedding_z=None, + )) + if row_profiles: + EP_save.objects.bulk_create(row_profiles, ignore_conflicts=True, batch_size=1000) + run.entity_count = len(row_profiles) + run.save(update_fields=['entity_count']) + except Exception as save_err: + logger.warning(f'Row labels save skipped: {save_err}') + # ── Step 3: UMAP-3D embedding (2D fallback) ─────────────────────── if run_umap: try: @@ -438,41 +468,23 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm, labels_list = cluster_entry.get('labels', []) if cluster_entry else [] from analysis.models import EntityProfile as EP - from analysis.models import ClusterResult as CR - profiles = [] - cr_cache = {} + # Update existing EP records with UMAP coordinates for i in range(len(df_umap)): - # Use row index as identifier — no entity column ev = f'row_{i}' - lbl = labels_list[i] if i < len(labels_list) else -1 - cache_key = f'{run.id}_{lbl}' - if cache_key not in cr_cache: - cr_cache[cache_key] = CR.objects.filter( - run=run, cluster_label=lbl).first() has_z = umap_components >= 3 and coords.shape[1] >= 3 - profiles.append(EP( - entity_value=ev, run=run, cluster_label=lbl, - cluster=cr_cache[cache_key], - embedding_x=float(coords[i, 0]) if i < len(coords) else None, - embedding_y=float(coords[i, 1]) if i < len(coords) else None, - embedding_z=float(coords[i, 2]) if has_z - and i < len(coords) else None, - )) - if profiles: - EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000) - run.entity_count = len(profiles) - run.save(update_fields=['entity_count']) + update_fields = { + 'embedding_x': float(coords[i, 0]) if i < len(coords) else None, + 'embedding_y': float(coords[i, 1]) if i < len(coords) else None, + } + if has_z: + update_fields['embedding_z'] = float(coords[i, 2]) if i < len(coords) else None + EP.objects.filter(run=run, entity_value=ev).update(**update_fields) + run.entity_count = len(df_umap) + run.save(update_fields=['entity_count']) except Exception as umap_err: logger.warning(f'UMAP embedding skipped: {umap_err}') - # Fallback: if entity_count not set (UMAP skipped), derive from cluster sizes - if run.entity_count is None: - from django.db.models import Sum - total = run.clusters.aggregate(total=Sum('size'))['total'] or 0 - run.entity_count = total - run.save(update_fields=['entity_count']) - # ── Done ── run.status = 'completed' run.progress_pct = 100