Saltar al contenido
Volver a todos los artículos
Architecture Development WordPress 29 Apr 2026 · 12 min de lectura

Cómo construí una calculadora de evaluaciones que valida contra el Excel real de una profesora

Francisco Silva

Francisco Silva

Socio Sénior de Ingeniería WordPress.

Cómo construí una calculadora de evaluaciones que valida contra el Excel real de una profesora

Un enfoque de Diseño Orientado al Dominio para el cálculo de calificaciones en PHP, con pruebas de verdad absoluta contra 23 alumnos × 2 trimestres × 8 instrumentos. Sin «nosotros», solo un desarrollador, una profesora y la disciplina de hacer que las matemáticas cuadren.

Estoy desarrollando un SaaS para profesores portugueses. No un sistema de gestión del aprendizaje, no un portal para padres, no otra plataforma EdTech más — simplemente la herramienta específica y concreta que usa un profesor para hacer seguimiento de las calificaciones de sus alumnos en varias clases y trimestres, con los cálculos que exige el sistema de educación básica portugués (medidas de inclusión del DL 54/2018, calificación por dominios múltiples, ponderación por período, conversión a niveles).

El mercado está dominado por Excel. Los profesores construyen hojas de cálculo que calculan medias trimestrales, notas finales ponderadas y conversiones a niveles. Estas hojas son frágiles, inconsistentes entre compañeros y propensas a roturas silenciosas de fórmulas. Una referencia de celda errónea puede propagar errores a 28 alumnos antes de que nadie lo detecte.

Mi beta tester es una profesora de Matemáticas que tiene precisamente ese Excel. Real, en uso en producción, refinado durante años. Veintitrés alumnos de 5.º de primaria, datos de dos trimestres, ocho instrumentos de calificación en dos dominios. Mi trabajo consiste en replicar exactamente lo que hace su Excel — pero como aplicación web más rápida, más fiable y escalable a otros profesores.

Este artículo trata sobre la disciplina de ingeniería que hay detrás de ese «exactamente». Concretamente: cómo construí un motor de cálculo en el que confío, validado contra la verdad absoluta de la hoja de cálculo de una profesora real.

El problema del cálculo, en términos sencillos

El cálculo de calificaciones de un profesor portugués tiene, a grandes rasgos, esta forma:

Para cada trimestre:

  • La clase tiene una plantilla de criterios de calificación, organizada en dominios (p. ej., «Conocimiento» 60 %, «Actitudes» 40 %)
  • Cada dominio tiene instrumentos con asignaciones de puntos explícitas (Examen = 40 pts, Trabajos = 10 pts, Participación = 10 pts, etc.)
  • Los puntos de los instrumentos de todos los dominios suman 100
  • La nota trimestral de un alumno es la suma de los puntos obtenidos en cada instrumento
  • Ese porcentaje se convierte a un nivel (del 1 al 5) usando umbrales — pero con un detalle: los umbrales están en el ,45 % (p. ej., 49,45 %, 69,45 %) en lugar del ,5 %, de modo que un alumno con un 49,44 % cae en el nivel 2 mientras que un 49,45 % cae en el nivel 3

Para la nota final a lo largo de varios trimestres:

  • Trimestre 1: directa (la propia nota trimestral)
  • Trimestre 2: 35 % × T1 + 65 % × T2
  • Trimestre 3: 33 % × T1 + 34 % × T2 + 33 % × T3

Sencillo en principio. Brutal en los detalles.

Por qué esto necesita diseño orientado al dominio

Si has pasado tiempo en el mundo de los plugins de WordPress, reconocerás la tentación: meterlo todo en una enorme class GradeCalculator con una docena de métodos públicos, incrustar consultas $wpdb directamente, mezclar las preocupaciones de presentación con la lógica de cálculo y enviarlo.

Rechacé ese camino por una razón: tengo que ser capaz de mirar a la profesora a los ojos y decirle que los cálculos son correctos. Eso requiere tres cosas:

  1. La lógica de cálculo debe poder probarse de forma aislada, sin arrancar WordPress
  2. Los datos que fluyen a través de ella deben ser explícitos e inmutables
  3. Cada regla de negocio (los umbrales del ,45 %, la ponderación por período, el tratamiento de puntuaciones ausentes) debe residir en un único lugar obvio

La estructura del código refleja esto:

includes/Domain/
├── Calculator/
│   ├── Calculator.php              # Orquestador
│   ├── InstrumentAggregator.php    # Agrega puntuaciones dentro de un instrumento
│   ├── PeriodAggregator.php        # Aplica la fórmula entre trimestres
│   ├── LevelConverter.php          # Mapeo porcentaje → nivel
│   ├── StudentScoreset.php         # Datos de entrada inmutables
│   └── GradeBreakdown.php          # Datos de salida inmutables
├── Models/
│   ├── CriteriaTemplate.php        # La plantilla de calificación (dominios, instrumentos)
│   └── Domain/Instrument/...       # Submodelos
└── ValueObjects/
    ├── LevelThresholds.php         # Los valores del ,45 %, encapsulados
    └── PeriodAggregationFormula.php # Las ponderaciones 35/65, encapsuladas

Toda la carpeta Domain/ tiene cero sentencias use que apunten a WordPress. Sin $wpdb, sin llamadas a wp_*(), sin get_option(). El Calculator puede probarse con PHPUnit estándar y migraría íntegro a Laravel, Symfony o una herramienta de línea de comandos.

La clase Calculator en sí

Aquí está el orquestador, al completo. Léelo una vez antes de que lo explique — la propia estructura te dice lo que está ocurriendo.

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainModelsCriteriaTemplate;
use AvaliarDomainValueObjectsLevelThresholds;
use AvaliarDomainValueObjectsPeriodAggregationFormula;

final readonly class Calculator
{
    public function __construct(
        private InstrumentAggregator $instrumentAggregator = new InstrumentAggregator(),
        private PeriodAggregator $periodAggregator = new PeriodAggregator(),
        private LevelConverter $levelConverter = new LevelConverter(),
    ) {}

    public function periodGrade(
        CriteriaTemplate $template,
        StudentScoreset $scores
    ): GradeBreakdown {
        $pointsByInstrument = [];
        $subtotalsByDomain = [];
        $total = 0.0;
        $maxTotal = 0.0;

        foreach ($template->domains as $domain) {
            $domainSubtotal = 0.0;
            foreach ($domain->instruments as $instrument) {
                $points = $this->instrumentAggregator->pointsFor($instrument, $scores);
                $pointsByInstrument[$instrument->id] = $points;
                $domainSubtotal += $points;
                $maxTotal += $instrument->maxScore;
            }
            $subtotalsByDomain[$domain->id] = $domainSubtotal;
            $total += $domainSubtotal;
        }

        return new GradeBreakdown($total, $maxTotal, $pointsByInstrument, $subtotalsByDomain);
    }

    public function finalGrade(
        PeriodAggregationFormula $formula,
        array $periodGradesByOrder
    ): float {
        return $this->periodAggregator->finalGrade($formula, $periodGradesByOrder);
    }

    public function level(float $gradePercentage, LevelThresholds $thresholds): int
    {
        return $this->levelConverter->toLevel($gradePercentage, $thresholds);
    }
}

Observa tres cosas:

final readonly class — sin herencia, sin mutación. El Calculator se construye una vez y se invoca muchas veces con diferentes entradas. No hay estado interno que pueda corromperse.

Constructor con dependencias instanciadas por defecto — en los tests puedo pasar agregadores simulados; en producción los valores por defecto funcionan. Las expresiones por defecto de PHP 8.1+ en los constructores son maravillosas para esto.

Tres métodos públicos, un propósito cada unoperiodGrade(), finalGrade(), level(). Sin punto de entrada que «lo haga todo». El código que llama los compone en el orden que el negocio necesita.

Objetos de valor: donde vive el ,45 %

Un fallo de diseño habitual es dejar «números mágicos» dispersos por el código. Los umbrales de nivel portugueses son 19,45 %, 49,45 %, 69,45 %, 89,45 % — y equivocarse en un 0,05 % mueve silenciosamente a alumnos entre niveles.

En lugar de codificarlos directamente en el Calculator, los convertí en un objeto de valor:

declare(strict_types=1);

namespace AvaliarDomainValueObjects;

final readonly class LevelThresholds
{
    /**
     * @param float[] $values Umbrales estrictamente crecientes.
     *                        Para 5 niveles (sistema portugués): [19.45, 49.45, 69.45, 89.45]
     */
    public function __construct(public array $values)
    {
        // Validar: estrictamente crecientes, todos en [0, 100]
        $previous = -INF;
        foreach ($values as $v) {
            if ($v < 0 || $v > 100) {
                throw new InvalidArgumentException("Threshold {$v} out of range");
            }
            if ($v <= $previous) {
                throw new InvalidArgumentException("Thresholds must be strictly increasing");
            }
            $previous = $v;
        }
    }

    public function maxLevel(): int
    {
        return count($this->values) + 1;
    }
}

La validación en el constructor detecta toda la clase de errores en los que alguien configura accidentalmente [19.45, 49.45, 49.45, 89.45] (duplicado) o [19.45, 89.45, 49.45] (desordenado). Es mejor fallar de forma ruidosa en el momento de crear la plantilla que calcular silenciosamente mal el nivel de un alumno seis meses después.

El LevelConverter se vuelve entonces ridículamente pequeño:

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainValueObjectsLevelThresholds;

final readonly class LevelConverter
{
    public function toLevel(float $gradePercentage, LevelThresholds $thresholds): int
    {
        foreach ($thresholds->values as $i => $threshold) {
            if ($gradePercentage < $threshold) {
                return $i + 1;
            }
        }
        return $thresholds->maxLevel();
    }
}

Observa el «menor que estricto» (<). Un alumno con exactamente 49,45 % no está por debajo del umbral — pertenece al nivel superior. Esto coincide con lo que hace el Excel de la profesora, y es el tipo de detalle que, con un signo igual de diferencia, es indetectable sin una verdad absoluta de referencia.

Fórmula entre trimestres: donde Excel y PHP se encuentran

La pieza más delicada es la fórmula de agregación por períodos. El trimestre 2 no es simplemente «la nota del trimestre 2» — es 0,35 × T1 + 0,65 × T2. El trimestre 3 es 0,33 × T1 + 0,34 × T2 + 0,33 × T3. Y de forma crítica, antes de que el tercer trimestre tenga datos, la fórmula tiene que producir un número de todas formas — usando 0 para el trimestre ausente.

Aquí está el gestor de la fórmula:

declare(strict_types=1);

namespace AvaliarDomainCalculator;

use AvaliarDomainValueObjectsPeriodAggregationFormula;

final readonly class PeriodAggregator
{
    public function finalGrade(
        PeriodAggregationFormula $formula,
        array $periodGradesByOrder
    ): float {
        if ($formula->isDirect()) {
            if ($periodGradesByOrder === []) {
                return 0.0;
            }
            // Modo directo: usar el período de mayor orden (el «actual»)
            $maxOrder = max(array_keys($periodGradesByOrder));
            return (float) $periodGradesByOrder[$maxOrder];
        }

        $sum = 0.0;
        foreach ($formula->weights as $order => $weight) {
            if (!isset($periodGradesByOrder[$order])) {
                throw new InvalidArgumentException(
                    sprintf('Missing grade for period order %d', $order)
                );
            }
            $sum += $periodGradesByOrder[$order] * ($weight / 100.0);
        }
        return $sum;
    }
}

La decisión interesante aquí es el throw en períodos ausentes. El Excel de la profesora, al encontrarse con un trimestre 3 ausente, lo trata como 0 y calcula el parcial. Así, si T1 es 70 %, T2 es 85 % y T3 aún no ha empezado:

Final T3 = 0.33 × 70 + 0.34 × 85 + 0.33 × 0 = 52.0%

Ese número no tiene sentido por sí solo — un alumno no tiene realmente un 52 %. Pero es lo que produce el Excel, y es lo que usa la profesora para hacer seguimiento de «hacia dónde se dirige este alumno». Reproduzco ese comportamiento exigiendo que el código que llama pase explícitamente 0 para los períodos ausentes, en lugar de sustituirlo silenciosamente. La excepción obliga al código que llama a pensar qué significa «ausente» en cada contexto.

Verdad absoluta: la prueba que importa

Aquí es donde este proyecto difiere de la mayoría de los motores de cálculo que he visto: tengo el Excel real de mi beta tester, anonimizado, como un fixture JSON. Veintitrés alumnos, datos de dos trimestres, con sus porcentajes finales y niveles tal como los calcula el Excel.

El fixture vive en tests/fixtures/ground-truth/:

ground-truth-complete.json   # todo: plantilla + alumnos + evaluaciones
template.json                # solo la configuración de criterios
students-summary.json        # alumnos con porcentaje final + nivel por trimestre
assessments.json             # exámenes y trabajos con puntuaciones por pregunta

Y la prueba que cierra el círculo:

class GroundTruthTest extends TestCase
{
    private const FIXTURES_DIR = __DIR__ . '/../../fixtures/ground-truth/';
    private const DELTA = 0.001; // tolerancia de punto flotante

    private array $groundTruth;

    protected function setUp(): void
    {
        $this->groundTruth = json_decode(
            file_get_contents(self::FIXTURES_DIR . 'ground-truth-complete.json'),
            true
        );
    }

    public function testAllStudentsMatchTerm1GroundTruth(): void
    {
        $template = $this->buildTemplateFromFixture();
        $calculator = new Calculator();

        foreach ($this->groundTruth['students'] as $student) {
            $term1 = $student['periods']['1P'];
            $scoreset = $this->buildScoresetFor1P($student);

            $breakdown = $calculator->periodGrade($template, $scoreset);
            $level = $calculator->level(
                $breakdown->percentage(),
                $template->levelThresholds
            );

            $this->assertEqualsWithDelta(
                $term1['final_percent'],
                $breakdown->percentage(),
                self::DELTA,
                "Alumno {$student['number']}: divergencia en el porcentaje final del trimestre 1"
            );

            $this->assertEquals(
                $term1['level'],
                $level,
                "Alumno {$student['number']}: divergencia en el nivel del trimestre 1"
            );
        }
    }
}

El criterio de validación es binario: ¿coincide la salida de mi Calculator con la del Excel, con una tolerancia de 0,001? Si es así para los 23 alumnos en dos trimestres, publico. Si cualquier alumno diverge, tengo un error.

El assertEqualsWithDelta es importante. Excel produce valores como 0.39999999999999997 cuando sus fórmulas tienen operaciones encadenadas. La aritmética de punto flotante de PHP producirá polvo de punto flotante diferente. La tolerancia de 0,001 detecta errores reales (divergencia de dos puntos porcentuales en el límite de nivel) sin generar ruido por fantasmas de IEEE 754.

Lo que aprendí

La verdad absoluta cambia la conversación. Cuando la profesora pregunta «¿estás seguro de que los cálculos son correctos?», no digo «sí, lo he probado». Digo «aquí está la prueba que compara a los 23 alumnos contra tu Excel; pasa». Eso es un tipo de confianza diferente.

El aislamiento del dominio se amortiza la primera vez que cambias WordPress. El Calculator no tiene ni idea de que WordPress existe. Cuando eventualmente sustituya $wpdb por otra cosa, o el cálculo se ejecute en un proceso batch por línea de comandos, el código del Calculator no se mueve. Solo cambian los repositorios que obtienen datos y los pasan al Calculator.

Los objetos de valor detectan errores en la frontera. El constructor de LevelThresholds valida que los valores sean estrictamente crecientes y estén dentro del rango. Si un futuro formulario de administración permite que alguien configure [20, 50, 50, 90], el sistema lo rechaza en el momento de guardar la plantilla, no cuando se muestra la nota del alumno. El radio de explosión del error es un único envío de formulario, no 28 alumnos por dos trimestres.

La aritmética de punto flotante es un requisito funcional, no una nota al pie. Los umbrales del ,45 % no están ahí para ser ingeniosos — están porque los valores redondeados al ,5 % crean bordes ambiguos. El Excel de la profesora usa el ,45 % deliberadamente. Mi Calculator en PHP también tiene que usarlos. Equivocarse en 0,05 cambia qué alumnos obtienen el nivel 3 en lugar del nivel 4, lo que determina si aprueban el año.

Qué viene después

El Calculator está en la v1.0 — bloqueado, probado contra la verdad absoluta, en uso en producción por una profesora. Las siguientes capas que estoy construyendo sobre él:

  • Sobrescrituras de configuración: un profesor puede desviarse de la plantilla del departamento (ponderaciones diferentes para una clase específica), pero la lógica del Calculator no cambia
  • Medidas de inclusión del DL 54/2018: los alumnos con adaptaciones curriculares necesitan sus propias plantillas; el Calculator recibe un CriteriaTemplate diferente por alumno cuando es necesario
  • Plantillas multicentro: cuando un centro adopta el sistema, su coordinador define plantillas que todos los profesores del departamento heredan

Nada de esto requiere tocar el Calculator. Requiere nuevos repositorios, nuevos servicios, nuevos objetos de valor. El Calculator sigue siendo un trozo de código pequeño, aburrido y bien probado que hace una sola cosa.

Ese es el objetivo del diseño orientado al dominio, resumido: hacer que las partes que importan sean lo suficientemente pequeñas como para poder razonar sobre ellas, y lo suficientemente pequeñas como para poder demostrar que son correctas.

Si estás construyendo algo donde los cálculos tienen que coincidir con el modelo mental de un experto en el dominio — nóminas, calificaciones, facturación, instrumentos científicos — y no sabes cómo estructurar el código para poder defender las matemáticas, hablemos. Tengo opiniones, y cuestan menos que descubrir un error de cálculo seis meses después del lanzamiento.

#case-study #domain-driven-design #education #php #software-architecture #testing

Compartir artículo

Últimos Insights